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: (
+
+ {cloud.availabilityZones.map((zone, index) => (
+ -
+ {zone}
+
+ ))}
+
+ ),
+ });
+ }
+
+ 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: (
+
+ {cloud.machineTypes.map((type, index) => (
+ -
+ {type}
+
+ ))}
+
+ ),
+ });
+ }
+
+ 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