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 c15fb7110c473..982b9c76934e8 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 @@ -1,5 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Error AGENT 1`] = ` +Object { + "name": "java", + "version": "agent version", +} +`; + exports[`Error AGENT_NAME 1`] = `"java"`; exports[`Error AGENT_VERSION 1`] = `"agent version"`; @@ -8,14 +15,26 @@ exports[`Error CLIENT_GEO 1`] = `undefined`; exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; +exports[`Error CLOUD 1`] = ` +Object { + "availability_zone": "europe-west1-c", + "provider": "gcp", + "region": "europe-west1", +} +`; + exports[`Error CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`; +exports[`Error CLOUD_MACHINE_TYPE 1`] = `undefined`; + exports[`Error CLOUD_PROVIDER 1`] = `"gcp"`; exports[`Error CLOUD_REGION 1`] = `"europe-west1"`; exports[`Error CLS_FIELD 1`] = `undefined`; +exports[`Error CONTAINER 1`] = `undefined`; + exports[`Error CONTAINER_ID 1`] = `undefined`; exports[`Error DESTINATION_ADDRESS 1`] = `undefined`; @@ -42,12 +61,20 @@ exports[`Error FCP_FIELD 1`] = `undefined`; exports[`Error FID_FIELD 1`] = `undefined`; +exports[`Error HOST 1`] = ` +Object { + "hostname": "my hostname", +} +`; + exports[`Error HOST_NAME 1`] = `"my hostname"`; exports[`Error HTTP_REQUEST_METHOD 1`] = `undefined`; exports[`Error HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; +exports[`Error KUBERNETES 1`] = `undefined`; + exports[`Error LABEL_NAME 1`] = `undefined`; exports[`Error LCP_FIELD 1`] = `undefined`; @@ -94,6 +121,16 @@ exports[`Error POD_NAME 1`] = `undefined`; exports[`Error PROCESSOR_EVENT 1`] = `"error"`; +exports[`Error SERVICE 1`] = ` +Object { + "language": Object { + "name": "nodejs", + "version": "v1337", + }, + "name": "service name", +} +`; + exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`; @@ -176,6 +213,13 @@ exports[`Error USER_AGENT_OS 1`] = `undefined`; exports[`Error USER_ID 1`] = `undefined`; +exports[`Span AGENT 1`] = ` +Object { + "name": "java", + "version": "agent version", +} +`; + exports[`Span AGENT_NAME 1`] = `"java"`; exports[`Span AGENT_VERSION 1`] = `"agent version"`; @@ -184,14 +228,26 @@ exports[`Span CLIENT_GEO 1`] = `undefined`; exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; +exports[`Span CLOUD 1`] = ` +Object { + "availability_zone": "europe-west1-c", + "provider": "gcp", + "region": "europe-west1", +} +`; + exports[`Span CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`; +exports[`Span CLOUD_MACHINE_TYPE 1`] = `undefined`; + exports[`Span CLOUD_PROVIDER 1`] = `"gcp"`; exports[`Span CLOUD_REGION 1`] = `"europe-west1"`; exports[`Span CLS_FIELD 1`] = `undefined`; +exports[`Span CONTAINER 1`] = `undefined`; + exports[`Span CONTAINER_ID 1`] = `undefined`; exports[`Span DESTINATION_ADDRESS 1`] = `undefined`; @@ -218,12 +274,16 @@ exports[`Span FCP_FIELD 1`] = `undefined`; exports[`Span FID_FIELD 1`] = `undefined`; +exports[`Span HOST 1`] = `undefined`; + exports[`Span HOST_NAME 1`] = `undefined`; exports[`Span HTTP_REQUEST_METHOD 1`] = `undefined`; exports[`Span HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; +exports[`Span KUBERNETES 1`] = `undefined`; + exports[`Span LABEL_NAME 1`] = `undefined`; exports[`Span LCP_FIELD 1`] = `undefined`; @@ -270,6 +330,12 @@ exports[`Span POD_NAME 1`] = `undefined`; exports[`Span PROCESSOR_EVENT 1`] = `"span"`; +exports[`Span SERVICE 1`] = ` +Object { + "name": "service name", +} +`; + exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`; @@ -352,6 +418,13 @@ exports[`Span USER_AGENT_OS 1`] = `undefined`; exports[`Span USER_ID 1`] = `undefined`; +exports[`Transaction AGENT 1`] = ` +Object { + "name": "java", + "version": "agent version", +} +`; + exports[`Transaction AGENT_NAME 1`] = `"java"`; exports[`Transaction AGENT_VERSION 1`] = `"agent version"`; @@ -360,14 +433,26 @@ exports[`Transaction CLIENT_GEO 1`] = `undefined`; exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; +exports[`Transaction CLOUD 1`] = ` +Object { + "availability_zone": "europe-west1-c", + "provider": "gcp", + "region": "europe-west1", +} +`; + exports[`Transaction CLOUD_AVAILABILITY_ZONE 1`] = `"europe-west1-c"`; +exports[`Transaction CLOUD_MACHINE_TYPE 1`] = `undefined`; + exports[`Transaction CLOUD_PROVIDER 1`] = `"gcp"`; exports[`Transaction CLOUD_REGION 1`] = `"europe-west1"`; exports[`Transaction CLS_FIELD 1`] = `undefined`; +exports[`Transaction CONTAINER 1`] = `"container1234567890abcdef"`; + exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; exports[`Transaction DESTINATION_ADDRESS 1`] = `undefined`; @@ -394,12 +479,26 @@ exports[`Transaction FCP_FIELD 1`] = `undefined`; exports[`Transaction FID_FIELD 1`] = `undefined`; +exports[`Transaction HOST 1`] = ` +Object { + "hostname": "my hostname", +} +`; + exports[`Transaction HOST_NAME 1`] = `"my hostname"`; exports[`Transaction HTTP_REQUEST_METHOD 1`] = `"GET"`; exports[`Transaction HTTP_RESPONSE_STATUS_CODE 1`] = `200`; +exports[`Transaction KUBERNETES 1`] = ` +Object { + "pod": Object { + "uid": "pod1234567890abcdef", + }, +} +`; + exports[`Transaction LABEL_NAME 1`] = `undefined`; exports[`Transaction LCP_FIELD 1`] = `undefined`; @@ -446,6 +545,16 @@ exports[`Transaction POD_NAME 1`] = `undefined`; exports[`Transaction PROCESSOR_EVENT 1`] = `"transaction"`; +exports[`Transaction SERVICE 1`] = ` +Object { + "language": Object { + "name": "nodejs", + "version": "v1337", + }, + "name": "service name", +} +`; + exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 18b8dc57c88db..a25566bced008 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -4,10 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +export const CLOUD = 'cloud'; 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 SERVICE = 'service'; export const SERVICE_NAME = 'service.name'; export const SERVICE_ENVIRONMENT = 'service.environment'; export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; @@ -19,6 +22,7 @@ export const SERVICE_RUNTIME_VERSION = 'service.runtime.version'; export const SERVICE_NODE_NAME = 'service.node.name'; export const SERVICE_VERSION = 'service.version'; +export const AGENT = 'agent'; export const AGENT_NAME = 'agent.name'; export const AGENT_VERSION = 'agent.version'; @@ -102,8 +106,11 @@ export const METRIC_JAVA_GC_TIME = 'jvm.gc.time'; export const LABEL_NAME = 'labels.name'; +export const HOST = 'host'; export const HOST_NAME = 'host.hostname'; +export const CONTAINER = 'container.id'; export const CONTAINER_ID = 'container.id'; +export const KUBERNETES = 'kubernetes'; export const POD_NAME = 'kubernetes.pod.name'; export const CLIENT_GEO_COUNTRY_ISO_CODE = 'client.geo.country_iso_code'; diff --git a/x-pack/plugins/apm/common/service_metadata.ts b/x-pack/plugins/apm/common/service_metadata.ts new file mode 100644 index 0000000000000..050f3055ac165 --- /dev/null +++ b/x-pack/plugins/apm/common/service_metadata.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export type ContainerType = 'Kubernetes' | 'Docker' | undefined; diff --git a/x-pack/plugins/apm/public/components/app/service_details/index.tsx b/x-pack/plugins/apm/public/components/app/service_details/index.tsx index 70acc2038e1a7..bccce6e823668 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/index.tsx @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiTitle } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { ApmHeader } from '../../shared/ApmHeader'; +import { ServiceIcons } from './service_icons'; import { ServiceDetailTabs } from './service_detail_tabs'; interface Props extends RouteComponentProps<{ serviceName: string }> { @@ -20,9 +21,16 @@ export function ServiceDetails({ match, tab }: Props) { return (
- -

{serviceName}

-
+ + + +

{serviceName}

+
+
+ + + +
diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx new file mode 100644 index 0000000000000..637a9d57344b3 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx @@ -0,0 +1,94 @@ +/* + * 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 { EuiBadge, EuiDescriptionList } from '@elastic/eui'; +import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; + +type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; + +interface Props { + cloud: ServiceDetailsReturnType['cloud']; +} + +export function CloudDetails({ cloud }: Props) { + if (!cloud) { + return null; + } + + const listItems: EuiDescriptionListProps['listItems'] = []; + if (cloud.provider) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.providerLabel', + { + defaultMessage: 'Cloud provider', + } + ), + description: cloud.provider, + }); + } + + if (!!cloud.availabilityZones?.length) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.availabilityZoneLabel', + { + defaultMessage: + '{zones, plural, =0 {Availability zone} one {Availability zone} other {Availability zones}} ', + values: { zones: cloud.availabilityZones.length }, + } + ), + description: ( + + ), + }); + } + + if (cloud.machineTypes) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel', + { + defaultMessage: + '{machineTypes, plural, =0{Machine type} one {Machine type} other {Machine types}} ', + values: { machineTypes: cloud.machineTypes.length }, + } + ), + description: ( + + ), + }); + } + + if (cloud.projectName) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.cloud.projectIdLabel', + { + defaultMessage: 'Project ID', + } + ), + description: cloud.projectName, + }); + } + + return ; +} diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx new file mode 100644 index 0000000000000..4c0f476da4c29 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx @@ -0,0 +1,81 @@ +/* + * 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 { EuiDescriptionList } from '@elastic/eui'; +import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { asInteger } from '../../../../../common/utils/formatters'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; + +type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; + +interface Props { + container: ServiceDetailsReturnType['container']; +} + +export function ContainerDetails({ container }: Props) { + if (!container) { + return null; + } + + const listItems: EuiDescriptionListProps['listItems'] = []; + if (container.os) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.container.osLabel', + { + defaultMessage: 'OS', + } + ), + description: container.os, + }); + } + + if (container.isContainerized !== undefined) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.container.containerizedLabel', + { defaultMessage: 'Containerized' } + ), + description: container.isContainerized + ? i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.container.yesLabel', + { + defaultMessage: 'Yes', + } + ) + : i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.container.noLabel', + { + defaultMessage: 'No', + } + ), + }); + } + + if (container.totalNumberInstances) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.container.totalNumberInstancesLabel', + { defaultMessage: 'Total number of instances' } + ), + description: asInteger(container.totalNumberInstances), + }); + } + + if (container.type) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.container.orchestrationLabel', + { defaultMessage: 'Orchestration' } + ), + description: container.type, + }); + } + + return ; +} diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx new file mode 100644 index 0000000000000..fa890260a3060 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx @@ -0,0 +1,64 @@ +/* + * 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 { + EuiButtonEmpty, + EuiIcon, + EuiLoadingContent, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import React from 'react'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { px } from '../../../../style/variables'; + +interface IconPopoverProps { + title: string; + children: React.ReactChild; + onOpen: () => void; + onClose: () => void; + detailsFetchStatus: FETCH_STATUS; + isOpen: boolean; + icon?: string; +} +export function IconPopover({ + icon, + title, + children, + onOpen, + onClose, + detailsFetchStatus, + isOpen, +}: IconPopoverProps) { + if (!icon) { + return null; + } + const isLoading = + detailsFetchStatus === FETCH_STATUS.LOADING || + detailsFetchStatus === FETCH_STATUS.PENDING; + + return ( + + + + } + isOpen={isOpen} + closePopover={onClose} + > + {title} +
+ {isLoading ? ( + + ) : ( + children + )} +
+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx new file mode 100644 index 0000000000000..5981e7408ef2c --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx @@ -0,0 +1,232 @@ +/* + * 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 { fireEvent, render } from '@testing-library/react'; +import { CoreStart } from 'kibana/public'; +import { merge } from 'lodash'; +// import { renderWithTheme } from '../../../../utils/testHelpers'; +import React, { ReactNode } from 'react'; +import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; +import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; +import { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; +import { + mockApmPluginContextValue, + MockApmPluginContextWrapper, +} from '../../../../context/apm_plugin/mock_apm_plugin_context'; +import * as fetcherHook from '../../../../hooks/use_fetcher'; +import { ServiceIcons } from './'; + +const KibanaReactContext = createKibanaReactContext({ + usageCollection: { reportUiCounter: () => {} }, +} as Partial); + +const addWarning = jest.fn(); +const httpGet = jest.fn(); + +function Wrapper({ children }: { children?: ReactNode }) { + const mockPluginContext = (merge({}, mockApmPluginContextValue, { + core: { http: { get: httpGet }, notifications: { toasts: { addWarning } } }, + }) as unknown) as ApmPluginContextValue; + + return ( + + + + {children} + + + + ); +} + +describe('ServiceIcons', () => { + describe('icons', () => { + it('Shows loading spinner while fetching data', () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: undefined, + status: fetcherHook.FETCH_STATUS.LOADING, + refetch: jest.fn(), + }); + const { getByTestId, queryAllByTestId } = render( + + + + ); + expect(getByTestId('loading')).toBeInTheDocument(); + expect(queryAllByTestId('service')).toHaveLength(0); + expect(queryAllByTestId('container')).toHaveLength(0); + expect(queryAllByTestId('cloud')).toHaveLength(0); + }); + it("doesn't show any icons", () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: {}, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + + const { queryAllByTestId } = render( + + + + ); + expect(queryAllByTestId('loading')).toHaveLength(0); + expect(queryAllByTestId('service')).toHaveLength(0); + expect(queryAllByTestId('container')).toHaveLength(0); + expect(queryAllByTestId('cloud')).toHaveLength(0); + }); + it('shows service icon', () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: { + agentName: 'java', + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + + const { queryAllByTestId, getByTestId } = render( + + + + ); + expect(queryAllByTestId('loading')).toHaveLength(0); + expect(getByTestId('service')).toBeInTheDocument(); + expect(queryAllByTestId('container')).toHaveLength(0); + expect(queryAllByTestId('cloud')).toHaveLength(0); + }); + it('shows service and container icons', () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: { + agentName: 'java', + containerType: 'Kubernetes', + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + + const { queryAllByTestId, getByTestId } = render( + + + + ); + expect(queryAllByTestId('loading')).toHaveLength(0); + expect(queryAllByTestId('cloud')).toHaveLength(0); + expect(getByTestId('service')).toBeInTheDocument(); + expect(getByTestId('container')).toBeInTheDocument(); + }); + it('shows service, container and cloud icons', () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: { + agentName: 'java', + containerType: 'Kubernetes', + cloudProvider: 'gcp', + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + + const { queryAllByTestId, getByTestId } = render( + + + + ); + expect(queryAllByTestId('loading')).toHaveLength(0); + expect(getByTestId('service')).toBeInTheDocument(); + expect(getByTestId('container')).toBeInTheDocument(); + expect(getByTestId('cloud')).toBeInTheDocument(); + }); + }); + + describe('details', () => { + const callApmApi = (apisMockData: Record) => ({ + endpoint, + }: { + endpoint: string; + }) => { + return apisMockData[endpoint]; + }; + it('Shows loading spinner while fetching data', () => { + const apisMockData = { + 'GET /api/apm/services/{serviceName}/metadata/icons': { + data: { + agentName: 'java', + containerType: 'Kubernetes', + cloudProvider: 'gcp', + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + 'GET /api/apm/services/{serviceName}/metadata/details': { + data: undefined, + status: fetcherHook.FETCH_STATUS.LOADING, + refetch: jest.fn(), + }, + }; + jest + .spyOn(fetcherHook, 'useFetcher') + .mockImplementation((func: Function, deps: string[]) => { + return func(callApmApi(apisMockData)) || {}; + }); + + const { queryAllByTestId, getByTestId } = render( + + + + ); + expect(queryAllByTestId('loading')).toHaveLength(0); + expect(getByTestId('service')).toBeInTheDocument(); + expect(getByTestId('container')).toBeInTheDocument(); + expect(getByTestId('cloud')).toBeInTheDocument(); + fireEvent.click(getByTestId('popover_Service')); + expect(getByTestId('loading-content')).toBeInTheDocument(); + }); + + it('shows service content', () => { + const apisMockData = { + 'GET /api/apm/services/{serviceName}/metadata/icons': { + data: { + agentName: 'java', + containerType: 'Kubernetes', + cloudProvider: 'gcp', + }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + 'GET /api/apm/services/{serviceName}/metadata/details': { + data: { service: { versions: ['v1.0.0'] } }, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }, + }; + jest + .spyOn(fetcherHook, 'useFetcher') + .mockImplementation((func: Function, deps: string[]) => { + return func(callApmApi(apisMockData)) || {}; + }); + + const { queryAllByTestId, getByTestId, getByText } = render( + + + + ); + expect(queryAllByTestId('loading')).toHaveLength(0); + expect(getByTestId('service')).toBeInTheDocument(); + expect(getByTestId('container')).toBeInTheDocument(); + expect(getByTestId('cloud')).toBeInTheDocument(); + + fireEvent.click(getByTestId('popover_Service')); + expect(queryAllByTestId('loading-content')).toHaveLength(0); + expect(getByText('Service')).toBeInTheDocument(); + expect(getByText('v1.0.0')).toBeInTheDocument(); + }); + }); +}); 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 new file mode 100644 index 0000000000000..327198e46131f --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx @@ -0,0 +1,161 @@ +/* + * 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, EuiLoadingSpinner } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { ReactChild, useState } from 'react'; +import { ContainerType } from '../../../../../common/service_metadata'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { getAgentIcon } from '../../../shared/AgentIcon/get_agent_icon'; +import { CloudDetails } from './cloud_details'; +import { ContainerDetails } from './container_details'; +import { IconPopover } from './icon_popover'; +import { ServiceDetails } from './service_details'; + +interface Props { + serviceName: string; +} + +const cloudIcons: Record = { + gcp: 'logoGCP', + aws: 'logoAWS', + azure: 'logoAzure', +}; + +function getCloudIcon(provider?: string) { + if (provider) { + return cloudIcons[provider]; + } +} + +function getContainerIcon(container?: ContainerType) { + if (!container) { + return; + } + switch (container) { + case 'Kubernetes': + return 'logoKubernetes'; + default: + return 'logoDocker'; + } +} + +type Icons = 'service' | 'container' | 'cloud'; +interface PopoverItem { + key: Icons; + icon?: string; + isVisible: boolean; + title: string; + component: ReactChild; +} + +export function ServiceIcons({ serviceName }: Props) { + const { + urlParams: { start, end }, + uiFilters, + } = useUrlParams(); + const [ + selectedIconPopover, + setSelectedIconPopover, + ] = useState(); + + const { data: icons, status: iconsFetchStatus } = useFetcher( + (callApmApi) => { + if (serviceName && start && end) { + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/metadata/icons', + params: { + path: { serviceName }, + query: { start, end, uiFilters: JSON.stringify(uiFilters) }, + }, + }); + } + }, + [serviceName, start, end, uiFilters] + ); + + const { data: details, status: detailsFetchStatus } = useFetcher( + (callApmApi) => { + if (selectedIconPopover && serviceName && start && end) { + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/metadata/details', + params: { + path: { serviceName }, + query: { start, end, uiFilters: JSON.stringify(uiFilters) }, + }, + }); + } + }, + [selectedIconPopover, serviceName, start, end, uiFilters] + ); + + const isLoading = + !icons && + (iconsFetchStatus === FETCH_STATUS.LOADING || + iconsFetchStatus === FETCH_STATUS.PENDING); + + if (isLoading) { + return ; + } + + const popoverItems: PopoverItem[] = [ + { + key: 'service', + icon: getAgentIcon(icons?.agentName) || 'node', + isVisible: !!icons?.agentName, + title: i18n.translate('xpack.apm.serviceIcons.service', { + defaultMessage: 'Service', + }), + component: , + }, + { + key: 'container', + icon: getContainerIcon(icons?.containerType), + isVisible: !!icons?.containerType, + title: i18n.translate('xpack.apm.serviceIcons.container', { + defaultMessage: 'Container', + }), + component: , + }, + { + key: 'cloud', + icon: getCloudIcon(icons?.cloudProvider), + isVisible: !!icons?.cloudProvider, + title: i18n.translate('xpack.apm.serviceIcons.cloud', { + defaultMessage: 'Cloud', + }), + component: , + }, + ]; + + return ( + + {popoverItems.map((item) => { + if (item.isVisible) { + return ( + + { + setSelectedIconPopover(item.key); + }} + onClose={() => { + setSelectedIconPopover(null); + }} + > + {item.component} + + + ); + } + })} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx new file mode 100644 index 0000000000000..d9525f8256922 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx @@ -0,0 +1,88 @@ +/* + * 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 { EuiDescriptionList } from '@elastic/eui'; +import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; + +type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; + +interface Props { + service: ServiceDetailsReturnType['service']; +} + +export function ServiceDetails({ service }: Props) { + if (!service) { + return null; + } + + const listItems: EuiDescriptionListProps['listItems'] = []; + if (!!service.versions?.length) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.service.versionLabel', + { + defaultMessage: 'Service version', + } + ), + description: ( +
    + {service.versions.map((version, index) => ( +
  • {version}
  • + ))} +
+ ), + }); + } + + if (service.runtime) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.service.runtimeLabel', + { + defaultMessage: 'Runtime name & version', + } + ), + description: ( + <> + {service.runtime.name} {service.runtime.version} + + ), + }); + } + + if (service.framework) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.service.frameworkLabel', + { + defaultMessage: 'Framework name', + } + ), + description: service.framework, + }); + } + + if (service.agent) { + listItems.push({ + title: i18n.translate( + 'xpack.apm.serviceIcons.serviceDetails.service.agentLabel', + { + defaultMessage: 'Agent name & version', + } + ), + description: ( + <> + {service.agent.name} {service.agent.version} + + ), + }); + } + + return ; +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts new file mode 100644 index 0000000000000..6b17b0248fc17 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts @@ -0,0 +1,170 @@ +/* + * 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 { SortOptions } from '../../../../../typings/elasticsearch'; +import { + AGENT, + CLOUD, + CLOUD_AVAILABILITY_ZONE, + CLOUD_MACHINE_TYPE, + CONTAINER, + HOST, + KUBERNETES, + PROCESSOR_EVENT, + SERVICE, + SERVICE_NAME, + SERVICE_NODE_NAME, + SERVICE_VERSION, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { ContainerType } from '../../../common/service_metadata'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; + +type ServiceMetadataDetailsRaw = Pick< + TransactionRaw, + 'service' | 'agent' | 'host' | 'container' | 'kubernetes' | 'cloud' +>; + +interface ServiceMetadataDetails { + service?: { + versions?: string[]; + runtime?: { + name: string; + version: string; + }; + framework?: string; + agent: { + name: string; + version: string; + }; + }; + container?: { + os?: string; + isContainerized?: boolean; + totalNumberInstances?: number; + type?: ContainerType; + }; + cloud?: { + provider?: string; + availabilityZones?: string[]; + machineTypes?: string[]; + projectName?: string; + }; +} + +export async function getServiceMetadataDetails({ + serviceName, + setup, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; +}): Promise { + const { start, end, apmEventClient } = setup; + + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { range: rangeFilter(start, end) }, + ...setup.esFilter, + ]; + + const should = [ + { exists: { field: CONTAINER } }, + { exists: { field: KUBERNETES } }, + { exists: { field: CLOUD } }, + { exists: { field: HOST } }, + { exists: { field: AGENT } }, + ]; + + const params = { + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + size: 1, + _source: [SERVICE, AGENT, HOST, CONTAINER, KUBERNETES, CLOUD], + query: { bool: { filter, should } }, + aggs: { + serviceVersions: { + terms: { + field: SERVICE_VERSION, + size: 10, + order: { _key: 'desc' } as SortOptions, + }, + }, + availabilityZones: { + terms: { + field: CLOUD_AVAILABILITY_ZONE, + size: 10, + }, + }, + machineTypes: { + terms: { + field: CLOUD_MACHINE_TYPE, + size: 10, + }, + }, + totalNumberInstances: { cardinality: { field: SERVICE_NODE_NAME } }, + }, + }, + }; + + const response = await apmEventClient.search(params); + + if (response.hits.total.value === 0) { + return { + service: undefined, + container: undefined, + cloud: undefined, + }; + } + + const { service, agent, host, kubernetes, container, cloud } = response.hits + .hits[0]._source as ServiceMetadataDetailsRaw; + + const serviceMetadataDetails = { + versions: response.aggregations?.serviceVersions.buckets.map( + (bucket) => bucket.key as string + ), + runtime: service.runtime, + framework: service.framework?.name, + agent, + }; + + const totalNumberInstances = + response.aggregations?.totalNumberInstances.value; + + const containerDetails = + host || container || totalNumberInstances || kubernetes + ? { + os: host?.os?.platform, + type: (!!kubernetes ? 'Kubernetes' : 'Docker') as ContainerType, + isContainerized: !!container?.id, + totalNumberInstances, + } + : undefined; + + const cloudDetails = cloud + ? { + provider: cloud.provider, + projectName: cloud.project?.name, + availabilityZones: response.aggregations?.availabilityZones.buckets.map( + (bucket) => bucket.key as string + ), + machineTypes: response.aggregations?.machineTypes.buckets.map( + (bucket) => bucket.key as string + ), + } + : undefined; + + return { + service: serviceMetadataDetails, + container: containerDetails, + cloud: cloudDetails, + }; +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts new file mode 100644 index 0000000000000..d9d62fef084aa --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts @@ -0,0 +1,85 @@ +/* + * 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 { + AGENT_NAME, + CLOUD_PROVIDER, + CONTAINER_ID, + KUBERNETES, + PROCESSOR_EVENT, + SERVICE_NAME, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { ContainerType } from '../../../common/service_metadata'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; + +type ServiceMetadataIconsRaw = Pick< + TransactionRaw, + 'kubernetes' | 'cloud' | 'container' | 'agent' +>; + +interface ServiceMetadataIcons { + agentName?: string; + containerType?: ContainerType; + cloudProvider?: string; +} + +export async function getServiceMetadataIcons({ + serviceName, + setup, +}: { + serviceName: string; + setup: Setup & SetupTimeRange; +}): Promise { + const { start, end, apmEventClient } = setup; + + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { range: rangeFilter(start, end) }, + ...setup.esFilter, + ]; + + const params = { + apm: { + events: [ProcessorEvent.transaction], + }, + terminateAfter: 1, + body: { + size: 1, + _source: [KUBERNETES, CLOUD_PROVIDER, CONTAINER_ID, AGENT_NAME], + query: { bool: { filter } }, + }, + }; + + const response = await apmEventClient.search(params); + + if (response.hits.total.value === 0) { + return { + agentName: undefined, + containerType: undefined, + cloudProvider: undefined, + }; + } + + const { kubernetes, cloud, container, agent } = response.hits.hits[0] + ._source as ServiceMetadataIconsRaw; + + let containerType: ContainerType; + if (!!kubernetes) { + containerType = 'Kubernetes'; + } else if (!!container) { + containerType = 'Docker'; + } + + return { + agentName: agent?.name, + containerType, + cloudProvider: cloud?.provider, + }; +} 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 5e26371b043e8..aeaa6f90a7426 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,8 @@ import { serviceErrorGroupsRoute, serviceThroughputRoute, serviceDependenciesRoute, + serviceMetadataDetailsRoute, + serviceMetadataIconsRoute, serviceInstancesRoute, } from './services'; import { @@ -130,6 +132,8 @@ const createApmApi = () => { .add(serviceErrorGroupsRoute) .add(serviceThroughputRoute) .add(serviceDependenciesRoute) + .add(serviceMetadataDetailsRoute) + .add(serviceMetadataIconsRoute) .add(serviceInstancesRoute) // Agent configuration diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index bba6afc332242..17430e64d6c9b 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -22,6 +22,8 @@ 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'; +import { getServiceMetadataDetails } from '../lib/services/get_service_metadata_details'; +import { getServiceMetadataIcons } from '../lib/services/get_service_metadata_icons'; export const servicesRoute = createRoute({ endpoint: 'GET /api/apm/services', @@ -46,6 +48,36 @@ export const servicesRoute = createRoute({ }, }); +export const serviceMetadataDetailsRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/metadata/details', + params: t.type({ + path: t.type({ serviceName: t.string }), + query: t.intersection([uiFiltersRt, rangeRt]), + }), + options: { tags: ['access:apm'] }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { serviceName } = context.params.path; + + return getServiceMetadataDetails({ serviceName, setup }); + }, +}); + +export const serviceMetadataIconsRoute = createRoute({ + endpoint: 'GET /api/apm/services/{serviceName}/metadata/icons', + params: t.type({ + path: t.type({ serviceName: t.string }), + query: t.intersection([uiFiltersRt, rangeRt]), + }), + options: { tags: ['access:apm'] }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { serviceName } = context.params.path; + + return getServiceMetadataIcons({ serviceName, setup }); + }, +}); + export const serviceAgentNameRoute = createRoute({ endpoint: 'GET /api/apm/services/{serviceName}/agent_name', params: t.type({ diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts new file mode 100644 index 0000000000000..97e564a7beaad --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/cloud.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +export interface Cloud { + availability_zone?: string; + instance?: { + name: string; + id: string; + }; + machine?: { + type: string; + }; + project?: { + id: string; + name: string; + }; + provider?: string; + region?: string; + account?: { + id: string; + name: string; + }; + image?: { + id: string; + }; +} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index 68db3bca94641..c87bab4957b38 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -5,6 +5,7 @@ */ import { APMBaseDoc } from './apm_base_doc'; +import { Cloud } from './fields/cloud'; import { Container } from './fields/container'; import { Host } from './fields/host'; import { Http } from './fields/http'; @@ -64,4 +65,5 @@ export interface TransactionRaw extends APMBaseDoc { url?: Url; user?: User; user_agent?: UserAgent; + cloud?: Cloud; } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 19a6dc7043351..97cbff8398d45 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5019,7 +5019,6 @@ "xpack.apm.servicesTable.7xUpgradeServerMessage": "バージョン7.xより前からのアップグレードですか?また、\n APMサーバーインスタンスを7.0以降にアップグレードしていることも確認してください。", "xpack.apm.servicesTable.avgResponseTimeColumnLabel": "平均応答時間", "xpack.apm.servicesTable.environmentColumnLabel": "環境", - "xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 個の環境} other {# 個の環境}}", "xpack.apm.servicesTable.healthColumnLabel": "ヘルス", "xpack.apm.servicesTable.nameColumnLabel": "名前", "xpack.apm.servicesTable.noServicesLabel": "APM サービスがインストールされていないようです。追加しましょう!", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 684f297b26e5d..b52a51a7242f8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5023,7 +5023,6 @@ "xpack.apm.servicesTable.7xUpgradeServerMessage": "从 7.x 之前的版本升级?另外,确保您已将\n APM Server 实例升级到至少 7.0。", "xpack.apm.servicesTable.avgResponseTimeColumnLabel": "平均响应时间", "xpack.apm.servicesTable.environmentColumnLabel": "环境", - "xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 个环境} other {# 个环境}}", "xpack.apm.servicesTable.healthColumnLabel": "运行状况", "xpack.apm.servicesTable.nameColumnLabel": "名称", "xpack.apm.servicesTable.noServicesLabel": "似乎您没有安装任何 APM 服务。让我们添加一些!", diff --git a/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts b/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts index 4e1fc3957a6ea..94d8f65bb6dc7 100644 --- a/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts +++ b/x-pack/test/apm_api_integration/basic/tests/feature_controls.ts @@ -170,6 +170,20 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expectForbidden: expect403, expectResponse: expect200, }, + { + req: { + url: `/api/apm/services/foo/metadata/details?start=${start}&end=${end}&uiFilters=%7B%7D`, + }, + expectForbidden: expect403, + expectResponse: expect200, + }, + { + req: { + url: `/api/apm/services/foo/metadata/icons?start=${start}&end=${end}&uiFilters=%7B%7D`, + }, + expectForbidden: expect403, + expectResponse: expect200, + }, ]; const elasticsearchPrivileges = { 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 f50868ee76c1c..645d4078f9332 100644 --- a/x-pack/test/apm_api_integration/basic/tests/index.ts +++ b/x-pack/test/apm_api_integration/basic/tests/index.ts @@ -25,6 +25,8 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont loadTestFile(require.resolve('./services/throughput')); loadTestFile(require.resolve('./services/top_services')); loadTestFile(require.resolve('./services/transaction_types')); + loadTestFile(require.resolve('./services/service_details')); + loadTestFile(require.resolve('./services/service_icons')); }); describe('Service overview', function () { diff --git a/x-pack/test/apm_api_integration/basic/tests/services/service_details.ts b/x-pack/test/apm_api_integration/basic/tests/services/service_details.ts new file mode 100644 index 0000000000000..7aff62c070946 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/services/service_details.ts @@ -0,0 +1,134 @@ +/* + * 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 { 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]; + + describe('Service details', () => { + describe('when data is not loaded ', () => { + it('handles the empty state', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/metadata/details`, + query: { + start, + end, + 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)); + + it('returns java service details', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/metadata/details`, + query: { + start, + end, + uiFilters: '{}', + }, + }) + ); + + expect(response.status).to.be(200); + + expectSnapshot(response.body).toMatchInline(` + Object { + "container": Object { + "isContainerized": true, + "os": "Linux", + "totalNumberInstances": 1, + "type": "Kubernetes", + }, + "service": Object { + "agent": Object { + "ephemeral_id": "d27b2271-06b4-48c8-a02a-cfd963c0b4d0", + "name": "java", + "version": "1.19.1-SNAPSHOT.null", + }, + "framework": "Servlet API", + "runtime": Object { + "name": "Java", + "version": "11.0.9.1", + }, + "versions": Array [ + "2020-12-08 03:35:36", + ], + }, + } + `); + }); + + it('returns python service details', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-python/metadata/details`, + query: { + start, + end, + uiFilters: '{}', + }, + }) + ); + + expect(response.status).to.be(200); + + expectSnapshot(response.body).toMatchInline(` + Object { + "cloud": Object { + "availabilityZones": Array [ + "europe-west1-c", + ], + "machineTypes": Array [ + "n1-standard-4", + ], + "projectName": "elastic-observability", + "provider": "gcp", + }, + "container": Object { + "isContainerized": true, + "os": "linux", + "totalNumberInstances": 1, + "type": "Kubernetes", + }, + "service": Object { + "agent": Object { + "name": "python", + "version": "5.10.0", + }, + "framework": "django", + "runtime": Object { + "name": "CPython", + "version": "3.8.6", + }, + "versions": Array [ + "2020-12-08 03:35:35", + ], + }, + } + `); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/basic/tests/services/service_icons.ts b/x-pack/test/apm_api_integration/basic/tests/services/service_icons.ts new file mode 100644 index 0000000000000..22fda10685812 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/services/service_icons.ts @@ -0,0 +1,88 @@ +/* + * 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 { 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]; + + describe('Service icons', () => { + describe('when data is not loaded ', () => { + it('handles the empty state', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/metadata/icons`, + query: { + start, + end, + 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)); + + it('returns java service icons', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-java/metadata/icons`, + query: { + start, + end, + uiFilters: '{}', + }, + }) + ); + + expect(response.status).to.be(200); + + expectSnapshot(response.body).toMatchInline(` + Object { + "agentName": "java", + "containerType": "Kubernetes", + } + `); + }); + + it('returns python service icons', async () => { + const response = await supertest.get( + url.format({ + pathname: `/api/apm/services/opbeans-python/metadata/icons`, + query: { + start, + end, + uiFilters: '{}', + }, + }) + ); + + expect(response.status).to.be(200); + + expectSnapshot(response.body).toMatchInline(` + Object { + "agentName": "python", + "cloudProvider": "gcp", + "containerType": "Kubernetes", + } + `); + }); + }); + }); +} diff --git a/x-pack/typings/elasticsearch/aggregations.d.ts b/x-pack/typings/elasticsearch/aggregations.d.ts index acd36b0a78127..12542363fa8e8 100644 --- a/x-pack/typings/elasticsearch/aggregations.d.ts +++ b/x-pack/typings/elasticsearch/aggregations.d.ts @@ -183,6 +183,11 @@ export interface AggregationOptionsByType { metrics: { field: string } | MaybeReadonlyArray<{ field: string }>; sort: SortOptions; }; + avg_bucket: { + buckets_path: string; + gap_policy?: 'skip' | 'insert_zeros'; + format?: string; + }; } type AggregationType = keyof AggregationOptionsByType; @@ -390,6 +395,9 @@ interface AggregationResponsePart; } ]; + avg_bucket: { + value: number | null; + }; } type TopMetricsMap = TFieldName extends string