diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index cc1b6688daa4..67cf7977974d 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -23,8 +23,14 @@ Object { } `; +exports[`Error CLOUD_ACCOUNT_ID 1`] = `undefined`; + exports[`Error CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`; +exports[`Error CLOUD_INSTANCE_ID 1`] = `undefined`; + +exports[`Error CLOUD_INSTANCE_NAME 1`] = `undefined`; + exports[`Error CLOUD_MACHINE_TYPE 1`] = `undefined`; exports[`Error CLOUD_PROVIDER 1`] = `"gcp"`; @@ -258,8 +264,14 @@ Object { } `; +exports[`Span CLOUD_ACCOUNT_ID 1`] = `undefined`; + exports[`Span CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`; +exports[`Span CLOUD_INSTANCE_ID 1`] = `undefined`; + +exports[`Span CLOUD_INSTANCE_NAME 1`] = `undefined`; + exports[`Span CLOUD_MACHINE_TYPE 1`] = `undefined`; exports[`Span CLOUD_PROVIDER 1`] = `"gcp"`; @@ -485,8 +497,14 @@ Object { } `; +exports[`Transaction CLOUD_ACCOUNT_ID 1`] = `undefined`; + exports[`Transaction CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`; +exports[`Transaction CLOUD_INSTANCE_ID 1`] = `undefined`; + +exports[`Transaction CLOUD_INSTANCE_NAME 1`] = `undefined`; + exports[`Transaction CLOUD_MACHINE_TYPE 1`] = `undefined`; exports[`Transaction CLOUD_PROVIDER 1`] = `"gcp"`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index ffd05b281208..4b77a88e5400 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -10,6 +10,9 @@ export const CLOUD_AVAILABILITY_ZONE = 'cloud.availability_zone'; export const CLOUD_PROVIDER = 'cloud.provider'; export const CLOUD_REGION = 'cloud.region'; export const CLOUD_MACHINE_TYPE = 'cloud.machine.type'; +export const CLOUD_ACCOUNT_ID = 'cloud.account.id'; +export const CLOUD_INSTANCE_ID = 'cloud.instance.id'; +export const CLOUD_INSTANCE_NAME = 'cloud.instance.name'; export const SERVICE = 'service'; export const SERVICE_NAME = 'service.name'; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx index 7dde7ed3d145..f7bed4e09a69 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx @@ -30,13 +30,13 @@ const cloudIcons: Record = { azure: 'logoAzure', }; -function getCloudIcon(provider?: string) { +export function getCloudIcon(provider?: string) { if (provider) { return cloudIcons[provider]; } } -function getContainerIcon(container?: ContainerType) { +export function getContainerIcon(container?: ContainerType) { if (!container) { 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 78c8f151b82d..cd1ced183012 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 @@ -119,7 +119,7 @@ export function ServiceOverview({ {!isRumAgent && ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx index f52c2b083330..4da5ba5a4ae6 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx @@ -5,11 +5,16 @@ * 2.0. */ -import { EuiBasicTableColumn } from '@elastic/eui'; +import { + EuiBasicTableColumn, + EuiButtonIcon, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; +import React, { ReactNode } from 'react'; +import { ActionMenu } from '../../../../../../observability/public'; import { isJavaAgentName } from '../../../../../common/agent_name'; +import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; import { getServiceNodeName, SERVICE_NODE_NAME_MISSING, @@ -26,6 +31,7 @@ import { MetricOverviewLink } from '../../../shared/Links/apm/MetricOverviewLink import { ServiceNodeMetricOverviewLink } from '../../../shared/Links/apm/ServiceNodeMetricOverviewLink'; import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; import { getLatencyColumnLabel } from '../get_latency_column_label'; +import { InstanceActionsMenu } from './instance_actions_menu'; import { MainStatsServiceInstanceItem } from '../service_overview_instances_chart_and_table'; type ServiceInstanceDetailedStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/detailed_statistics'>; @@ -36,12 +42,20 @@ export function getColumns({ latencyAggregationType, detailedStatsData, comparisonEnabled, + toggleRowDetails, + itemIdToExpandedRowMap, + toggleRowActionMenu, + itemIdToOpenActionMenuRowMap, }: { serviceName: string; agentName?: string; latencyAggregationType?: LatencyAggregationType; detailedStatsData?: ServiceInstanceDetailedStatistics; comparisonEnabled?: boolean; + toggleRowDetails: (selectedServiceNodeName: string) => void; + itemIdToExpandedRowMap: Record; + toggleRowActionMenu: (selectedServiceNodeName: string) => void; + itemIdToOpenActionMenuRowMap: Record; }): Array> { return [ { @@ -82,7 +96,7 @@ export function getColumns({ sortable: true, }, { - field: 'latencyValue', + field: 'latency', name: getLatencyColumnLabel(latencyAggregationType), width: px(unit * 10), render: (_, { serviceNodeName, latency }) => { @@ -104,7 +118,7 @@ export function getColumns({ sortable: true, }, { - field: 'throughputValue', + field: 'throughput', name: i18n.translate( 'xpack.apm.serviceOverview.instancesTableColumnThroughput', { defaultMessage: 'Throughput' } @@ -130,7 +144,7 @@ export function getColumns({ sortable: true, }, { - field: 'errorRateValue', + field: 'errorRate', name: i18n.translate( 'xpack.apm.serviceOverview.instancesTableColumnErrorRate', { defaultMessage: 'Error rate' } @@ -156,7 +170,7 @@ export function getColumns({ sortable: true, }, { - field: 'cpuUsageValue', + field: 'cpuUsage', name: i18n.translate( 'xpack.apm.serviceOverview.instancesTableColumnCpuUsage', { defaultMessage: 'CPU usage (avg.)' } @@ -182,7 +196,7 @@ export function getColumns({ sortable: true, }, { - field: 'memoryUsageValue', + field: 'memoryUsage', name: i18n.translate( 'xpack.apm.serviceOverview.instancesTableColumnMemoryUsage', { defaultMessage: 'Memory usage (avg.)' } @@ -207,5 +221,56 @@ export function getColumns({ }, sortable: true, }, + { + width: '40px', + render: (instanceItem: MainStatsServiceInstanceItem) => { + return ( + + toggleRowActionMenu(instanceItem.serviceNodeName) + } + isOpen={itemIdToOpenActionMenuRowMap[instanceItem.serviceNodeName]} + anchorPosition="leftCenter" + button={ + + toggleRowActionMenu(instanceItem.serviceNodeName) + } + /> + } + > + toggleRowActionMenu(instanceItem.serviceNodeName)} + /> + + ); + }, + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: (instanceItem: MainStatsServiceInstanceItem) => { + return ( + toggleRowDetails(instanceItem.serviceNodeName)} + aria-label={ + itemIdToExpandedRowMap[instanceItem.serviceNodeName] + ? 'Collapse' + : 'Expand' + } + iconType={ + itemIdToExpandedRowMap[instanceItem.serviceNodeName] + ? 'arrowUp' + : 'arrowDown' + } + /> + ); + }, + }, ]; } 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 index 1bab5e45bcc5..fe367896c465 100644 --- 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 @@ -12,7 +12,7 @@ import { EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { ReactNode, useEffect, useState } from 'react'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; @@ -26,6 +26,7 @@ import { } from '../service_overview_instances_chart_and_table'; import { ServiceOverviewTableContainer } from '../service_overview_table_container'; import { getColumns } from './get_columns'; +import { InstanceDetails } from './intance_details'; type ServiceInstanceDetailedStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/detailed_statistics'>; @@ -65,15 +66,58 @@ export function ServiceOverviewInstancesTable({ urlParams: { latencyAggregationType, comparisonEnabled }, } = useUrlParams(); + const [ + itemIdToOpenActionMenuRowMap, + setItemIdToOpenActionMenuRowMap, + ] = useState>({}); + + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState< + Record + >({}); + + useEffect(() => { + // Closes any open rows when fetching new items + setItemIdToExpandedRowMap({}); + }, [status]); + const { pageIndex, sort } = tableOptions; const { direction, field } = sort; + const toggleRowActionMenu = (selectedServiceNodeName: string) => { + const actionMenuRowMapValues = { ...itemIdToOpenActionMenuRowMap }; + if (actionMenuRowMapValues[selectedServiceNodeName]) { + delete actionMenuRowMapValues[selectedServiceNodeName]; + } else { + actionMenuRowMapValues[selectedServiceNodeName] = true; + } + setItemIdToOpenActionMenuRowMap(actionMenuRowMapValues); + }; + + const toggleRowDetails = (selectedServiceNodeName: string) => { + const expandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (expandedRowMapValues[selectedServiceNodeName]) { + delete expandedRowMapValues[selectedServiceNodeName]; + } else { + expandedRowMapValues[selectedServiceNodeName] = ( + + ); + } + setItemIdToExpandedRowMap(expandedRowMapValues); + }; + const columns = getColumns({ agentName, serviceName, latencyAggregationType, detailedStatsData, comparisonEnabled, + toggleRowDetails, + itemIdToExpandedRowMap, + toggleRowActionMenu, + itemIdToOpenActionMenuRowMap, }); const pagination = { @@ -106,6 +150,8 @@ export function ServiceOverviewInstancesTable({ pagination={pagination} sorting={{ sort: { field, direction } }} onChange={onChangeTableOptions} + itemId="serviceNodeName" + itemIdToExpandedRowMap={itemIdToExpandedRowMap} /> diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/index.tsx new file mode 100644 index 000000000000..f03c2b2fc909 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/index.tsx @@ -0,0 +1,131 @@ +/* + * 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 { EuiLoadingSpinner } from '@elastic/eui'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { + ActionMenuDivider, + Section, + SectionLink, + SectionLinks, + SectionSubtitle, + SectionTitle, +} from '../../../../../../../observability/public'; +import { isJavaAgentName } from '../../../../../../common/agent_name'; +import { SERVICE_NODE_NAME } from '../../../../../../common/elasticsearch_fieldnames'; +import { useApmPluginContext } from '../../../../../context/apm_plugin/use_apm_plugin_context'; +import { useUrlParams } from '../../../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS } from '../../../../../hooks/use_fetcher'; +import { px } from '../../../../../style/variables'; +import { pushNewItemToKueryBar } from '../../../../shared/KueryBar/utils'; +import { useMetricOverviewHref } from '../../../../shared/Links/apm/MetricOverviewLink'; +import { useServiceNodeMetricOverviewHref } from '../../../../shared/Links/apm/ServiceNodeMetricOverviewLink'; +import { useInstanceDetailsFetcher } from '../use_instance_details_fetcher'; +import { getMenuSections } from './menu_sections'; + +interface Props { + serviceName: string; + serviceNodeName: string; + onClose: () => void; +} + +const POPOVER_WIDTH = px(305); + +export function InstanceActionsMenu({ + serviceName, + serviceNodeName, + onClose, +}: Props) { + const { core } = useApmPluginContext(); + const { data, status } = useInstanceDetailsFetcher({ + serviceName, + serviceNodeName, + }); + const serviceNodeMetricOverviewHref = useServiceNodeMetricOverviewHref({ + serviceName, + serviceNodeName, + }); + const metricOverviewHref = useMetricOverviewHref(serviceName); + const history = useHistory(); + const { + urlParams: { kuery }, + } = useUrlParams(); + + if ( + status === FETCH_STATUS.LOADING || + status === FETCH_STATUS.NOT_INITIATED + ) { + return ( +
+ +
+ ); + } + + if (!data) { + return null; + } + + const handleFilterByInstanceClick = () => { + onClose(); + pushNewItemToKueryBar({ + kuery, + history, + key: SERVICE_NODE_NAME, + value: serviceNodeName, + }); + }; + + const metricsHref = isJavaAgentName(data.agent?.name) + ? serviceNodeMetricOverviewHref + : metricOverviewHref; + + const sections = getMenuSections({ + instanceDetails: data, + basePath: core.http.basePath, + onFilterByInstanceClick: handleFilterByInstanceClick, + metricsHref, + }); + + return ( +
+ {sections.map((section, idx) => { + const isLastSection = idx !== sections.length - 1; + return ( +
+ {section.map((item) => ( +
+ {item.title && {item.title}} + {item.subtitle && ( + {item.subtitle} + )} + + {item.actions.map((action) => ( + + ))} + +
+ ))} + {isLastSection && } +
+ ); + })} +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts new file mode 100644 index 000000000000..30995fbd1339 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts @@ -0,0 +1,203 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { IBasePath } from 'kibana/public'; +import { isEmpty } from 'lodash'; +import moment from 'moment'; +import { APIReturnType } from '../../../../../services/rest/createCallApmApi'; +import { getInfraHref } from '../../../../shared/Links/InfraLink'; + +type InstaceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; + +interface Action { + key: string; + label: string; + href?: string; + onClick?: () => void; + condition: boolean; +} + +interface Section { + key: string; + title?: string; + subtitle?: string; + actions: Action[]; +} + +type SectionRecord = Record; + +function getInfraMetricsQuery(timestamp?: string) { + if (!timestamp) { + return { from: 0, to: 0 }; + } + const timeInMilliseconds = new Date(timestamp).getTime(); + const fiveMinutes = moment.duration(5, 'minutes').asMilliseconds(); + + return { + from: timeInMilliseconds - fiveMinutes, + to: timeInMilliseconds + fiveMinutes, + }; +} + +export function getMenuSections({ + instanceDetails, + basePath, + onFilterByInstanceClick, + metricsHref, +}: { + instanceDetails: InstaceDetails; + basePath: IBasePath; + onFilterByInstanceClick: () => void; + metricsHref: string; +}) { + const podId = instanceDetails.kubernetes?.pod?.uid; + const containerId = instanceDetails.container?.id; + const time = instanceDetails['@timestamp'] + ? new Date(instanceDetails['@timestamp']).valueOf() + : undefined; + const infraMetricsQuery = getInfraMetricsQuery(instanceDetails['@timestamp']); + + const podActions: Action[] = [ + { + key: 'podLogs', + label: i18n.translate( + 'xpack.apm.serviceOverview.instancesTable.actionMenus.podLogs', + { defaultMessage: 'Pod logs' } + ), + href: getInfraHref({ + app: 'logs', + basePath, + path: `/link-to/pod-logs/${podId}`, + query: { time }, + }), + condition: !!podId, + }, + { + key: 'podMetrics', + label: i18n.translate( + 'xpack.apm.serviceOverview.instancesTable.actionMenus.podMetrics', + { defaultMessage: 'Pod metrics' } + ), + href: getInfraHref({ + app: 'metrics', + basePath, + path: `/link-to/pod-detail/${podId}`, + query: infraMetricsQuery, + }), + condition: !!podId, + }, + ]; + + const containerActions: Action[] = [ + { + key: 'containerLogs', + label: i18n.translate( + 'xpack.apm.serviceOverview.instancesTable.actionMenus.containerLogs', + { defaultMessage: 'Container logs' } + ), + href: getInfraHref({ + app: 'logs', + basePath, + path: `/link-to/container-logs/${containerId}`, + query: { time }, + }), + condition: !!containerId, + }, + { + key: 'containerMetrics', + label: i18n.translate( + 'xpack.apm.serviceOverview.instancesTable.actionMenus.containerMetrics', + { defaultMessage: 'Container metrics' } + ), + href: getInfraHref({ + app: 'metrics', + basePath, + path: `/link-to/container-detail/${containerId}`, + query: infraMetricsQuery, + }), + condition: !!containerId, + }, + ]; + + const apmActions: Action[] = [ + { + key: 'filterByInstance', + label: i18n.translate( + 'xpack.apm.serviceOverview.instancesTable.actionMenus.filterByInstance', + { + defaultMessage: 'Filter overview by instance', + } + ), + onClick: onFilterByInstanceClick, + condition: true, + }, + { + key: 'analyzeRuntimeMetric', + label: i18n.translate( + 'xpack.apm.serviceOverview.instancesTable.actionMenus.metrics', + { + defaultMessage: 'Metrics', + } + ), + href: metricsHref, + condition: true, + }, + ]; + + const sectionRecord: SectionRecord = { + observability: [ + { + key: 'podDetails', + title: i18n.translate( + 'xpack.apm.serviceOverview.instancesTable.actionMenus.pod.title', + { + defaultMessage: 'Pod details', + } + ), + subtitle: i18n.translate( + 'xpack.apm.serviceOverview.instancesTable.actionMenus.pod.subtitle', + { + defaultMessage: + 'View logs and metrics for this pod to get further details.', + } + ), + actions: podActions, + }, + { + key: 'containerDetails', + title: i18n.translate( + 'xpack.apm.serviceOverview.instancesTable.actionMenus.container.title', + { + defaultMessage: 'Container details', + } + ), + subtitle: i18n.translate( + 'xpack.apm.serviceOverview.instancesTable.actionMenus.container.subtitle', + { + defaultMessage: + 'View logs and metrics for this container to get further details.', + } + ), + actions: containerActions, + }, + ], + apm: [{ key: 'apm', actions: apmActions }], + }; + + // Filter out actions that shouldnt be shown and sections without any actions. + return Object.values(sectionRecord) + .map((sections) => + sections + .map((section) => ({ + ...section, + actions: section.actions.filter((action) => action.condition), + })) + .filter((section) => !isEmpty(section.actions)) + ) + .filter((sections) => !isEmpty(sections)); +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx new file mode 100644 index 000000000000..f50d02bb1545 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx @@ -0,0 +1,144 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiLoadingContent } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { + CLOUD_AVAILABILITY_ZONE, + CLOUD_INSTANCE_ID, + CLOUD_INSTANCE_NAME, + CLOUD_MACHINE_TYPE, + CLOUD_PROVIDER, + CONTAINER_ID, + HOST_NAME, + POD_NAME, + SERVICE_NODE_NAME, + SERVICE_RUNTIME_NAME, + SERVICE_RUNTIME_VERSION, + SERVICE_VERSION, +} from '../../../../../common/elasticsearch_fieldnames'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { useTheme } from '../../../../hooks/use_theme'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { pct } from '../../../../style/variables'; +import { getAgentIcon } from '../../../shared/AgentIcon/get_agent_icon'; +import { KeyValueFilterList } from '../../../shared/key_value_filter_list'; +import { pushNewItemToKueryBar } from '../../../shared/KueryBar/utils'; +import { + getCloudIcon, + getContainerIcon, +} from '../../service_details/service_icons'; +import { useInstanceDetailsFetcher } from './use_instance_details_fetcher'; + +type ServiceInstanceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; + +interface Props { + serviceName: string; + serviceNodeName: string; +} + +function toKeyValuePairs(keys: string[], data: ServiceInstanceDetails) { + return keys.map((key) => ({ key, value: get(data, key) })); +} + +const serviceDetailsKeys = [ + SERVICE_NODE_NAME, + SERVICE_VERSION, + SERVICE_RUNTIME_NAME, + SERVICE_RUNTIME_VERSION, +]; +const containerDetailsKeys = [CONTAINER_ID, HOST_NAME, POD_NAME]; +const cloudDetailsKeys = [ + CLOUD_AVAILABILITY_ZONE, + CLOUD_INSTANCE_ID, + CLOUD_INSTANCE_NAME, + CLOUD_MACHINE_TYPE, + CLOUD_PROVIDER, +]; + +export function InstanceDetails({ serviceName, serviceNodeName }: Props) { + const theme = useTheme(); + const history = useHistory(); + const { + urlParams: { kuery }, + } = useUrlParams(); + + const { data, status } = useInstanceDetailsFetcher({ + serviceName, + serviceNodeName, + }); + + if ( + status === FETCH_STATUS.LOADING || + status === FETCH_STATUS.NOT_INITIATED + ) { + return ( +
+ +
+ ); + } + + if (!data) { + return null; + } + + const addKueryBarFilter = ({ key, value }: { key: string; value: any }) => { + pushNewItemToKueryBar({ kuery, history, key, value }); + }; + + const serviceDetailsKeyValuePairs = toKeyValuePairs(serviceDetailsKeys, data); + const containerDetailsKeyValuePairs = toKeyValuePairs( + containerDetailsKeys, + data + ); + const cloudDetailsKeyValuePairs = toKeyValuePairs(cloudDetailsKeys, data); + + const containerType = data.kubernetes?.pod?.name ? 'Kubernetes' : 'Docker'; + return ( + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/use_instance_details_fetcher.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/use_instance_details_fetcher.tsx new file mode 100644 index 000000000000..7a5da7e3e462 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/use_instance_details_fetcher.tsx @@ -0,0 +1,52 @@ +/* + * 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 { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; +import { useFetcher } from '../../../../hooks/use_fetcher'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; + +export function useInstanceDetailsFetcher({ + serviceName, + serviceNodeName, +}: { + serviceName: string; + serviceNodeName: string; +}) { + const { + urlParams: { start, end, kuery, environment }, + } = useUrlParams(); + const { transactionType } = useApmServiceContext(); + + const { data, status } = useFetcher( + (callApmApi) => { + if (!start || !end || !transactionType) { + return; + } + return callApmApi({ + endpoint: + 'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}', + params: { + path: { + serviceName, + serviceNodeName, + }, + query: { start, end, transactionType, environment, kuery }, + }, + }); + }, + [ + serviceName, + serviceNodeName, + start, + end, + transactionType, + environment, + kuery, + ] + ); + + return { data, status }; +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table_container.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table_container.tsx index 738ff0d7c735..64b6943e7326 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table_container.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_table_container.tsx @@ -32,7 +32,7 @@ const ServiceOverviewTableContainerDiv = euiStyled.div<{ shouldUseMobileLayout ? '' : ` - height: ${tableHeight}px; + min-height: ${tableHeight}px; display: flex; flex-direction: column; diff --git a/x-pack/plugins/apm/public/components/shared/KueryBar/utils.ts b/x-pack/plugins/apm/public/components/shared/KueryBar/utils.ts new file mode 100644 index 000000000000..56aed1227b1e --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/utils.ts @@ -0,0 +1,28 @@ +/* + * 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 { History } from 'history'; +import { isEmpty } from 'lodash'; +import { push } from '../Links/url_helpers'; + +export function pushNewItemToKueryBar({ + kuery, + history, + key, + value, +}: { + kuery?: string; + history: History; + key: string; + value: any; +}) { + const newItem = `${key} :"${value}"`; + const nextKuery = isEmpty(kuery) ? newItem : `${kuery} and ${newItem}`; + push(history, { + query: { kuery: encodeURIComponent(nextKuery) }, + }); +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx index 7ad7f18b425c..aad5756b70e7 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx @@ -5,40 +5,46 @@ * 2.0. */ +import { EuiLink } from '@elastic/eui'; import React from 'react'; -import { APMLink, APMLinkExtendProps } from './APMLink'; -import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { pickKeys } from '../../../../../common/utils/pick_keys'; +import { APMQueryParams } from '../url_helpers'; +import { APMLinkExtendProps, useAPMHref } from './APMLink'; interface Props extends APMLinkExtendProps { serviceName: string; serviceNodeName: string; } -function ServiceNodeMetricOverviewLink({ +const persistedFilters: Array = [ + 'host', + 'containerId', + 'podName', + 'serviceVersion', +]; + +export function useServiceNodeMetricOverviewHref({ + serviceName, + serviceNodeName, +}: { + serviceName: string; + serviceNodeName: string; +}) { + return useAPMHref({ + path: `/services/${serviceName}/nodes/${encodeURIComponent( + serviceNodeName + )}/metrics`, + persistedFilters, + }); +} + +export function ServiceNodeMetricOverviewLink({ serviceName, serviceNodeName, ...rest }: Props) { - const { urlParams } = useUrlParams(); - - const persistedFilters = pickKeys( - urlParams, - 'host', - 'containerId', - 'podName', - 'serviceVersion' - ); - - return ( - - ); + const href = useServiceNodeMetricOverviewHref({ + serviceName, + serviceNodeName, + }); + return ; } - -export { ServiceNodeMetricOverviewLink }; diff --git a/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx b/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx new file mode 100644 index 000000000000..c836919a8a6a --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/key_value_filter_list/index.tsx @@ -0,0 +1,147 @@ +/* + * 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 { + EuiAccordion, + EuiButtonEmpty, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { Fragment } from 'react'; +import styled from 'styled-components'; +import { px, units } from '../../../style/variables'; + +interface KeyValue { + key: string; + value: any | undefined; +} + +const StyledEuiAccordion = styled(EuiAccordion)` + width: 100%; + .buttonContentContainer .euiIEFlexWrapFix { + width: 100%; + } +`; + +const StyledEuiDescriptionList = styled(EuiDescriptionList)` + margin: ${px(units.half)} ${px(units.half)} 0 ${px(units.half)}; + .descriptionList__title, + .descriptionList__description { + border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; + margin-top: 0; + align-items: center; + display: flex; +`; + +const ValueContainer = styled.div` + display: flex; + align-items: center; +`; + +function removeEmptyValues(items: KeyValue[]) { + return items.filter(({ value }) => value !== undefined); +} + +export function KeyValueFilterList({ + icon, + title, + keyValueList, + initialIsOpen = false, + onClickFilter, +}: { + title: string; + keyValueList: KeyValue[]; + initialIsOpen?: boolean; + icon?: string; + onClickFilter: (filter: { key: string; value: any }) => void; +}) { + if (!keyValueList.length) { + return null; + } + + return ( + } + buttonClassName="buttonContentContainer" + > + + {removeEmptyValues(keyValueList).map(({ key, value }) => { + return ( + + + + {key} + + + + + { + onClickFilter({ key, value }); + }} + data-test-subj={`filter_by_${key}`} + > + + + + + {value} + + + + ); + })} + + + ); +} + +function AccordionButtonContent({ + icon, + title, +}: { + icon?: string; + title: string; +}) { + return ( + + {icon && ( + + + + )} + + {title} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/key_value_filter_list/key_value_filter_list.test.tsx b/x-pack/plugins/apm/public/components/shared/key_value_filter_list/key_value_filter_list.test.tsx new file mode 100644 index 000000000000..78a7698259e7 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/key_value_filter_list/key_value_filter_list.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 React from 'react'; +import { KeyValueFilterList } from './'; +import { + expectTextsInDocument, + renderWithTheme, +} from '../../../utils/testHelpers'; +import { fireEvent } from '@testing-library/react'; + +describe('KeyValueFilterList', () => { + it('hides accordion when key value list is empty', () => { + const { container } = renderWithTheme( + + ); + expect(container).toBeEmptyDOMElement(); + }); + it('shows list of key value pairs', () => { + const component = renderWithTheme( + + ); + expectTextsInDocument(component, [ + 'title', + 'foo', + 'foo value', + 'bar', + 'bar value', + ]); + }); + it('shows icon and title on accordion', () => { + const component = renderWithTheme( + + ); + expect(component.getByTestId('accordion_title_icon')).toBeInTheDocument(); + expectTextsInDocument(component, ['title']); + }); + it('hides icon and only shows title on accordion', () => { + const component = renderWithTheme( + + ); + expect(component.queryAllByTestId('accordion_title_icon')).toEqual([]); + expectTextsInDocument(component, ['title']); + }); + it('returns selected key value when the filter button is clicked', () => { + const mockFilter = jest.fn(); + const component = renderWithTheme( + + ); + + fireEvent.click(component.getByTestId('filter_by_foo')); + expect(mockFilter).toHaveBeenCalledWith({ key: 'foo', value: 'foo value' }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instance_metadata_details.ts b/x-pack/plugins/apm/server/lib/services/get_service_instance_metadata_details.ts new file mode 100644 index 000000000000..25935bcc37df --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_instance_metadata_details.ts @@ -0,0 +1,84 @@ +/* + * 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 { + SERVICE_NAME, + SERVICE_NODE_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { environmentQuery, kqlQuery, rangeQuery } from '../../utils/queries'; +import { withApmSpan } from '../../utils/with_apm_span'; +import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; + +export interface KeyValue { + key: string; + value: any | undefined; +} + +export async function getServiceInstanceMetadataDetails({ + serviceName, + serviceNodeName, + setup, + searchAggregatedTransactions, + transactionType, + environment, + kuery, +}: { + serviceName: string; + serviceNodeName: string; + setup: Setup & SetupTimeRange; + searchAggregatedTransactions: boolean; + transactionType: string; + environment?: string; + kuery?: string; +}) { + return withApmSpan('get_service_instance_metadata_details', async () => { + const { start, end, apmEventClient } = setup; + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [SERVICE_NODE_NAME]: serviceNodeName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ]; + + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + terminate_after: 1, + size: 1, + query: { bool: { filter } }, + }, + }); + + const sample = response.hits.hits[0]?._source; + + if (!sample) { + return {}; + } + + const { agent, service, container, kubernetes, host, cloud } = sample; + + return { + '@timestamp': sample['@timestamp'], + agent, + service, + container, + kubernetes, + host, + cloud, + }; + }); +} diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 30aa4cce45d0..a27c7d5ba38d 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -18,6 +18,7 @@ import { getServices } from '../lib/services/get_services'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServiceAlerts } from '../lib/services/get_service_alerts'; import { getServiceDependencies } from '../lib/services/get_service_dependencies'; +import { getServiceInstanceMetadataDetails } from '../lib/services/get_service_instance_metadata_details'; import { getServiceErrorGroupPeriods } from '../lib/services/get_service_error_groups/get_service_error_group_detailed_statistics'; import { getServiceErrorGroupMainStatistics } from '../lib/services/get_service_error_groups/get_service_error_group_main_statistics'; import { getServiceInstancesDetailedStatisticsPeriods } from '../lib/services/get_service_instances/detailed_statistics'; @@ -551,7 +552,44 @@ const serviceInstancesDetailedStatisticsRoute = createApmServerRoute({ }, }); -const serviceDependenciesRoute = createApmServerRoute({ +export const serviceInstancesMetadataDetails = createApmServerRoute({ + endpoint: + 'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}', + params: t.type({ + path: t.type({ + serviceName: t.string, + serviceNodeName: t.string, + }), + query: t.intersection([ + t.type({ transactionType: t.string }), + environmentRt, + kueryRt, + rangeRt, + ]), + }), + options: { tags: ['access:apm'] }, + handler: async (resources) => { + const setup = await setupRequest(resources); + const { serviceName, serviceNodeName } = resources.params.path; + const { transactionType, environment, kuery } = resources.params.query; + + const searchAggregatedTransactions = await getSearchAggregatedTransactions( + setup + ); + + return await getServiceInstanceMetadataDetails({ + searchAggregatedTransactions, + setup, + serviceName, + serviceNodeName, + transactionType, + environment, + kuery, + }); + }, +}); + +export const serviceDependenciesRoute = createApmServerRoute({ endpoint: 'GET /api/apm/services/{serviceName}/dependencies', params: t.type({ path: t.type({ @@ -724,6 +762,7 @@ export const serviceRouteRepository = createApmServerRouteRepository() .add(serviceAnnotationsCreateRoute) .add(serviceErrorGroupsMainStatisticsRoute) .add(serviceErrorGroupsDetailedStatisticsRoute) + .add(serviceInstancesMetadataDetails) .add(serviceThroughputRoute) .add(serviceInstancesMainStatisticsRoute) .add(serviceInstancesDetailedStatisticsRoute) diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/metric_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/metric_raw.ts index c79a35093df5..d7d015fd21da 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/metric_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/metric_raw.ts @@ -6,14 +6,22 @@ */ import { APMBaseDoc } from './apm_base_doc'; +import { Cloud } from './fields/cloud'; import { Container } from './fields/container'; +import { Host } from './fields/host'; import { Kubernetes } from './fields/kubernetes'; +import { Service } from './fields/service'; type BaseMetric = APMBaseDoc & { processor: { name: 'metric'; event: 'metric'; }; + cloud?: Cloud; + container?: Container; + kubernetes?: Kubernetes; + service?: Service; + host?: Host; }; type BaseBreakdownMetric = BaseMetric & { @@ -86,8 +94,6 @@ type TransactionDurationMetric = BaseMetric & { environment?: string; version?: string; }; - container?: Container; - kubernetes?: Kubernetes; }; export type SpanDestinationMetric = BaseMetric & { diff --git a/x-pack/test/apm_api_integration/tests/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index df1ed1db5900..7c38f37093fa 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -73,6 +73,10 @@ export default function apmApiIntegrationTests(providerContext: FtrProviderConte loadTestFile(require.resolve('./service_overview/instances_detailed_statistics')); }); + describe('service_overview/instance_details', function () { + loadTestFile(require.resolve('./service_overview/instance_details')); + }); + // Services describe('services/agent_name', function () { loadTestFile(require.resolve('./services/agent_name')); diff --git a/x-pack/test/apm_api_integration/tests/service_overview/__snapshots__/instance_details.snap b/x-pack/test/apm_api_integration/tests/service_overview/__snapshots__/instance_details.snap new file mode 100644 index 000000000000..b4197c7dfbf6 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/service_overview/__snapshots__/instance_details.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`APM API tests basic apm_8.0.0 Instance details when data is loaded fetch instance details return the correct data 1`] = ` +Object { + "@timestamp": "2020-12-08T13:59:01.971Z", + "agent": Object { + "ephemeral_id": "d27b2271-06b4-48c8-a02a-cfd963c0b4d0", + "name": "java", + "version": "1.19.1-SNAPSHOT.null", + }, + "container": Object { + "id": "02950c4c5fbb0fda1cc98c47bf4024b473a8a17629db6530d95dcee68bd54c6c", + }, + "host": Object { + "architecture": "amd64", + "ip": "10.8.4.45", + "os": Object { + "platform": "Linux", + }, + }, + "kubernetes": Object { + "pod": Object { + "name": "opbeans-java-6bdd78cb5c-k2qz6", + "uid": "805e875d-1fda-42c0-bb54-23eb6faf54ab", + }, + }, + "service": Object { + "environment": "production", + "framework": Object { + "name": "Servlet API", + }, + "language": Object { + "name": "Java", + "version": "11.0.9.1", + }, + "name": "opbeans-java", + "node": Object { + "name": "02950c4c5fbb0fda1cc98c47bf4024b473a8a17629db6530d95dcee68bd54c6c", + }, + "runtime": Object { + "name": "Java", + "version": "11.0.9.1", + }, + "version": "2020-12-08 03:35:36", + }, +} +`; diff --git a/x-pack/test/apm_api_integration/tests/service_overview/instance_details.ts b/x-pack/test/apm_api_integration/tests/service_overview/instance_details.ts new file mode 100644 index 000000000000..ee3966aa10a4 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/service_overview/instance_details.ts @@ -0,0 +1,101 @@ +/* + * 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 url from 'url'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import archives from '../../common/fixtures/es_archiver/archives_metadata'; +import { registry } from '../../common/registry'; +import { APIReturnType } from '../../../../plugins/apm/public/services/rest/createCallApmApi'; + +type ServiceOverviewInstanceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + const archiveName = 'apm_8.0.0'; + const { start, end } = archives[archiveName]; + + registry.when( + 'Instance details when data is not loaded', + { config: 'basic', archives: [] }, + () => { + describe('when data is not loaded', () => { + it('handles empty state', async () => { + const response = await supertest.get( + url.format({ + pathname: '/api/apm/services/opbeans-java/service_overview_instances/details/foo', + query: { + start, + end, + transactionType: 'request', + }, + }) + ); + + expect(response.status).to.be(200); + expect(response.body).to.eql({}); + }); + }); + } + ); + + registry.when( + 'Instance details when data is loaded', + { config: 'basic', archives: [archiveName] }, + () => { + describe('fetch instance details', () => { + let response: { + status: number; + body: ServiceOverviewInstanceDetails; + }; + + before(async () => { + response = await supertest.get( + url.format({ + pathname: + '/api/apm/services/opbeans-java/service_overview_instances/details/02950c4c5fbb0fda1cc98c47bf4024b473a8a17629db6530d95dcee68bd54c6c', + query: { + start, + end, + transactionType: 'request', + }, + }) + ); + }); + + it('returns the instance details', () => { + expect(response.body).to.not.eql({}); + }); + + it('return the correct data', () => { + expectSnapshot(response.body).toMatch(); + }); + }); + } + ); + + registry.when( + 'Instance details when data is loaded but details not found', + { config: 'basic', archives: [archiveName] }, + () => { + it('handles empty state when instance id not found', async () => { + const response = await supertest.get( + url.format({ + pathname: '/api/apm/services/opbeans-java/service_overview_instances/details/foo', + query: { + start, + end, + transactionType: 'request', + }, + }) + ); + expect(response.status).to.be(200); + expect(response.body).to.eql({}); + }); + } + ); +}