[APM] Add infrastructure metrics tables (#130223)

* [APM] Add infrastructure metrics tables

* hide container and pods tab when no data, added timepicker

* add container ids and pod names to host filter

* fix es types

* fix i18n

* wip

* added empty and failure prompts

* added new route to get host names

* copy changes and components for empty/failure state

* improve gethostnames query

* delete infra host route and move request to server side

* PR comments

* remove prop and the export from getHostNames

* update infra components, improve tables rendering UI

* fix useTabs test

Co-authored-by: Boris Kirov <borisasenovkirov@gmail.com>
This commit is contained in:
Miriam 2022-05-24 11:59:24 +03:00 committed by GitHub
parent b9c7b73644
commit e126ccc56f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 733 additions and 26 deletions

View file

@ -72,8 +72,7 @@ describe('Infrastracture feature flag', () => {
it('shows infrastructure tab in service overview page', () => {
cy.visit(serviceOverviewPath);
cy.contains('a[role="tab"]', 'Infrastructure').click();
cy.contains('Infrastructure data coming soon');
cy.contains('a[role="tab"]', 'Infrastructure');
});
});
});

View file

@ -4,21 +4,14 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiEmptyPrompt, EuiLoadingLogo } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiPanel } from '@elastic/eui';
import React from 'react';
import { InfraTabs } from './infra_tabs';
export function InfraOverview() {
return (
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoMetrics" size="xl" />}
title={
<h2>
{i18n.translate('xpack.apm.infra.announcement', {
defaultMessage: 'Infrastructure data coming soon',
})}
</h2>
}
/>
<EuiPanel color="subdued" borderRadius="none" hasShadow={false}>
<InfraTabs />
</EuiPanel>
);
}

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
export function EmptyPrompt() {
return (
<EuiPageTemplate
pageContentProps={{
color: 'transparent',
}}
template="centeredBody"
>
<EuiEmptyPrompt
iconType="metricsApp"
color="subdued"
title={
<h2>
{i18n.translate('xpack.apm.infraTabs.noMetricsPromptTitle', {
defaultMessage: 'No infrastructure data found',
})}
</h2>
}
titleSize="m"
body={
<p>
{i18n.translate('xpack.apm.infraTabs.noMetricsPromptDescription', {
defaultMessage:
'Try adjusting your time range or check if you have any metrics data set up.',
})}
</p>
}
/>
</EuiPageTemplate>
);
}

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
export function FailurePrompt() {
return (
<EuiPageTemplate
pageContentProps={{
color: 'transparent',
}}
template="centeredBody"
>
<EuiEmptyPrompt
color="danger"
iconType="alert"
layout="vertical"
title={
<h2>
{i18n.translate('xpack.apm.infraTabs.failurePromptTitle', {
defaultMessage: 'Unable to load your infrastructure data',
})}
</h2>
}
titleSize="m"
body={
<p>
{i18n.translate('xpack.apm.infraTabs.failurePromptDescription', {
defaultMessage:
'There was a problem loading the Infrastructure tab and your data. You can contact your administrator for help.',
})}
</p>
}
/>
</EuiPageTemplate>
);
}

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiTabbedContent, EuiLoadingSpinner } from '@elastic/eui';
import React from 'react';
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { EmptyPrompt } from './empty_prompt';
import { FailurePrompt } from './failure_prompt';
import { useTabs } from './use_tabs';
const INITIAL_STATE = {
containerIds: [],
hostNames: [],
podNames: [],
};
export function InfraTabs() {
const { serviceName } = useApmServiceContext();
const {
query: { environment, kuery, rangeFrom, rangeTo },
} = useApmParams('/services/{serviceName}/infrastructure');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { data = INITIAL_STATE, status } = useFetcher(
(callApmApi) => {
if (start && end) {
return callApmApi(
'GET /internal/apm/services/{serviceName}/infrastructure_attributes',
{
params: {
path: { serviceName },
query: {
environment,
kuery,
start,
end,
},
},
}
);
}
},
[environment, kuery, serviceName, start, end]
);
const { containerIds, podNames, hostNames } = data;
const tabs = useTabs({
containerIds,
podNames,
hostNames,
start,
end,
});
if (status === FETCH_STATUS.LOADING) {
return (
<div style={{ textAlign: 'center' }}>
<EuiLoadingSpinner size="xl" />
</div>
);
}
if (status === FETCH_STATUS.FAILURE) {
return (
<div style={{ textAlign: 'center' }}>
<FailurePrompt />
</div>
);
}
if (
status === FETCH_STATUS.SUCCESS &&
!containerIds.length &&
!podNames.length &&
!hostNames.length
) {
return (
<div style={{ textAlign: 'center' }}>
<EmptyPrompt />
</div>
);
}
return (
<>
<EuiTabbedContent
tabs={tabs}
initialSelectedTab={tabs[0]}
autoFocus="selected"
/>
</>
);
}

View file

@ -0,0 +1,118 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ReactNode } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useTabs } from './use_tabs';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { shallow } from 'enzyme';
const KibanaReactContext = createKibanaReactContext({
infra: {
HostMetricsTable: () => 'Host metrics table',
ContainerMetricsTable: () => 'Container metrics table',
PodMetricsTable: () => 'Pods metrics table',
},
} as unknown as Partial<CoreStart>);
function wrapper({ children }: { children: ReactNode }) {
return <KibanaReactContext.Provider>{children}</KibanaReactContext.Provider>;
}
describe('useTabs', () => {
describe('when we have container ids, pod names and host names', () => {
it('returns all the tabs', () => {
const params = {
containerIds: ['apple'],
podNames: ['orange'],
hostNames: ['banana'],
start: '2022-05-18T11:43:23.367Z',
end: '2022-05-18T11:58:23.367Z',
};
const { result } = renderHook(() => useTabs(params), { wrapper });
const tabs = [
{
id: 'containers',
name: 'Containers',
content: '<EuiSpacer />Container metrics table',
},
{
id: 'pods',
name: 'Pods',
content: '<EuiSpacer />Pods metrics table',
},
{
id: 'hosts',
name: 'Hosts',
content: '<EuiSpacer />Host metrics table',
},
];
tabs.forEach(({ id, name, content }, index) => {
const currentResult = result.current[index];
const component = shallow(<div>{currentResult.content}</div>);
expect(currentResult.id).toBe(id);
expect(currentResult.name).toBe(name);
expect(component.text()).toBe(content);
});
});
});
describe('when there are not container ids nor pod names', () => {
it('returns host tab', () => {
const params = {
containerIds: [],
podNames: [],
hostNames: ['banana'],
start: '2022-05-18T11:43:23.367Z',
end: '2022-05-18T11:58:23.367Z',
};
const { result } = renderHook(() => useTabs(params), { wrapper });
const tabs = [
{
id: 'hosts',
name: 'Hosts',
content: '<EuiSpacer />Host metrics table',
},
];
tabs.forEach(({ id, name, content }, index) => {
const currentResult = result.current[index];
const component = shallow(<div>{currentResult.content}</div>);
expect(currentResult.id).toBe(id);
expect(currentResult.name).toBe(name);
expect(component.text()).toBe(content);
});
});
});
describe('when there are not container ids nor pod names nor host names', () => {
it('returns host tab', () => {
const params = {
containerIds: [],
podNames: [],
hostNames: [],
start: '2022-05-18T11:43:23.367Z',
end: '2022-05-18T11:58:23.367Z',
};
const { result } = renderHook(() => useTabs(params), { wrapper });
const tabs = [
{
id: 'hosts',
name: 'Hosts',
content: '<EuiSpacer />Host metrics table',
},
];
tabs.forEach(({ id, name, content }, index) => {
const currentResult = result.current[index];
const component = shallow(<div>{currentResult.content}</div>);
expect(currentResult.id).toBe(id);
expect(currentResult.name).toBe(name);
expect(component.text()).toBe(content);
});
});
});
});

View file

@ -0,0 +1,130 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiTabbedContentProps } from '@elastic/eui';
import { useMemo } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import React from 'react';
import { EuiSpacer } from '@elastic/eui';
import { ApmPluginStartDeps } from '../../../../plugin';
type Tab = NonNullable<EuiTabbedContentProps['tabs']>[0] & {
id: 'containers' | 'pods' | 'hosts';
hidden?: boolean;
};
export function useTabs({
containerIds,
podNames,
hostNames,
start,
end,
}: {
containerIds: string[];
podNames: string[];
hostNames: string[];
start: string;
end: string;
}) {
const { services } = useKibana<ApmPluginStartDeps>();
const { infra } = services;
const HostMetricsTable = infra?.HostMetricsTable;
const ContainerMetricsTable = infra?.ContainerMetricsTable;
const PodMetricsTable = infra?.PodMetricsTable;
const timerange = useMemo(
() => ({
from: start,
to: end,
}),
[start, end]
);
const hostsFilter = useMemo(
(): QueryDslQueryContainer => ({
bool: {
should: [
{
terms: {
'host.name': hostNames,
},
},
],
minimum_should_match: 1,
},
}),
[hostNames]
);
const podsFilter = useMemo(
() => ({
bool: {
filter: [{ terms: { 'kubernetes.pod.name': podNames } }],
},
}),
[podNames]
);
const containersFilter = useMemo(
() => ({
bool: {
filter: [{ terms: { 'container.id': containerIds } }],
},
}),
[containerIds]
);
const containerMetricsTable = (
<>
<EuiSpacer />
{ContainerMetricsTable &&
ContainerMetricsTable({ timerange, filterClauseDsl: containersFilter })}
</>
);
const podMetricsTable = (
<>
<EuiSpacer />
{PodMetricsTable &&
PodMetricsTable({ timerange, filterClauseDsl: podsFilter })}
</>
);
const hostMetricsTable = (
<>
<EuiSpacer />
{HostMetricsTable &&
HostMetricsTable({ timerange, filterClauseDsl: hostsFilter })}
</>
);
const tabs: Tab[] = [
{
id: 'containers',
name: 'Containers',
content: containerMetricsTable,
hidden: containerIds && containerIds.length <= 0,
},
{
id: 'pods',
name: 'Pods',
content: podMetricsTable,
hidden: podNames && podNames.length <= 0,
},
{
id: 'hosts',
name: 'Hosts',
content: hostMetricsTable,
},
];
return tabs
.filter((t) => !t.hidden)
.map(({ id, name, content }) => ({
id,
name,
content,
}));
}

View file

@ -36,7 +36,7 @@ export function ServiceLogs() {
(callApmApi) => {
if (start && end) {
return callApmApi(
'GET /internal/apm/services/{serviceName}/infrastructure',
'GET /internal/apm/services/{serviceName}/infrastructure_attributes_for_logs',
{
params: {
path: { serviceName },
@ -96,7 +96,7 @@ export function ServiceLogs() {
export const getInfrastructureKQLFilter = (
data:
| APIReturnType<'GET /internal/apm/services/{serviceName}/infrastructure'>
| APIReturnType<'GET /internal/apm/services/{serviceName}/infrastructure_attributes_for_logs'>
| undefined,
serviceName: string
) => {

View file

@ -269,14 +269,16 @@ export const serviceDetail = {
}),
element: <ServiceProfiling />,
}),
'/services/{serviceName}/infra': page({
tab: 'infra',
'/services/{serviceName}/infrastructure': page({
tab: 'infrastructure',
title: i18n.translate('xpack.apm.views.infra.title', {
defaultMessage: 'Infrastructure',
}),
element: <InfraOverview />,
searchBarOptions: {
hidden: true,
showKueryBar: false,
showTimeComparison: false,
showTransactionTypeSelector: false,
},
}),
'/services/{serviceName}/': {

View file

@ -44,7 +44,7 @@ type Tab = NonNullable<EuiPageHeaderProps['tabs']>[0] & {
| 'errors'
| 'metrics'
| 'nodes'
| 'infra'
| 'infrastructure'
| 'service-map'
| 'logs'
| 'profiling';
@ -249,8 +249,8 @@ function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) {
hidden: isJVMsTabHidden({ agentName, runtimeName }),
},
{
key: 'infra',
href: router.link('/services/{serviceName}/infra', {
key: 'infrastructure',
href: router.link('/services/{serviceName}/infrastructure', {
path: { serviceName },
query,
}),

View file

@ -47,6 +47,7 @@ import type {
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
import { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { enableServiceGroups } from '@kbn/observability-plugin/public';
import { InfraClientStartExports } from '@kbn/infra-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { registerApmAlerts } from './components/alerting/register_apm_alerts';
import {
@ -89,6 +90,7 @@ export interface ApmPluginStartDeps {
fleet?: FleetStart;
security?: SecurityPluginStart;
spaces?: SpacesPluginStart;
infra?: InfraClientStartExports;
dataViews: DataViewsPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
}

View file

@ -15,6 +15,7 @@ import { alertsChartPreviewRouteRepository } from '../alerts/route';
import { backendsRouteRepository } from '../backends/route';
import { environmentsRouteRepository } from '../environments/route';
import { errorsRouteRepository } from '../errors/route';
import { infrastructureRouteRepository } from '../infrastructure/route';
import { apmFleetRouteRepository } from '../fleet/route';
import { dataViewRouteRepository } from '../data_view/route';
import { latencyDistributionRouteRepository } from '../latency_distribution/route';
@ -69,6 +70,7 @@ function getTypedGlobalApmServerRouteRepository() {
...eventMetadataRouteRepository,
...agentKeysRouteRepository,
...spanLinksRouteRepository,
...infrastructureRouteRepository,
...debugTelemetryRoute,
};

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient } from '@kbn/core/server';
import { rangeQuery } from '@kbn/observability-plugin/server';
import { InfraPluginStart, InfraPluginSetup } from '@kbn/infra-plugin/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
CONTAINER_ID,
HOST_NAME,
} from '../../../common/elasticsearch_fieldnames';
import { ApmPluginRequestHandlerContext } from '../typings';
import { getMetricIndices } from '../../lib/helpers/get_metric_indices';
interface Aggs extends estypes.AggregationsMultiBucketAggregateBase {
buckets: Array<{
key: string;
key_as_string?: string;
}>;
}
interface InfraPlugin {
setup: InfraPluginSetup;
start: () => Promise<InfraPluginStart>;
}
const getHostNames = async ({
esClient,
containerIds,
index,
start,
end,
}: {
esClient: ElasticsearchClient;
containerIds: string[];
index: string;
start: number;
end: number;
}) => {
const response = await esClient.search<unknown, { hostNames: Aggs }>({
index: [index],
body: {
size: 0,
query: {
bool: {
filter: [
{
terms: {
[CONTAINER_ID]: containerIds,
},
},
...rangeQuery(start, end),
],
},
},
aggs: {
hostNames: {
terms: {
field: HOST_NAME,
size: 500,
},
},
},
},
});
return {
hostNames:
response.aggregations?.hostNames?.buckets.map(
(bucket) => bucket.key as string
) ?? [],
};
};
export const getContainerHostNames = async ({
containerIds,
context,
infra,
start,
end,
}: {
containerIds: string[];
context: ApmPluginRequestHandlerContext;
infra: InfraPlugin;
start: number;
end: number;
}): Promise<string[]> => {
if (containerIds.length) {
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const savedObjectsClient = (await context.core).savedObjects.client;
const metricIndices = await getMetricIndices({
infraPlugin: infra,
savedObjectsClient,
});
const containerHostNames = await getHostNames({
esClient,
containerIds,
index: metricIndices,
start,
end,
});
return containerHostNames.hostNames;
}
return [];
};

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { rangeQuery, kqlQuery } from '@kbn/observability-plugin/server';
import { Setup } from '../../lib/helpers/setup_request';
import { environmentQuery } from '../../../common/utils/environment_query';
import { ProcessorEvent } from '../../../common/processor_event';
import {
SERVICE_NAME,
CONTAINER_ID,
HOST_HOSTNAME,
POD_NAME,
} from '../../../common/elasticsearch_fieldnames';
export const getInfrastructureData = async ({
kuery,
serviceName,
environment,
setup,
start,
end,
}: {
kuery: string;
serviceName: string;
environment: string;
setup: Setup;
start: number;
end: number;
}) => {
const { apmEventClient } = setup;
const response = await apmEventClient.search('get_service_infrastructure', {
apm: {
events: [ProcessorEvent.metric],
},
body: {
size: 0,
query: {
bool: {
filter: [
{ term: { [SERVICE_NAME]: serviceName } },
...rangeQuery(start, end),
...environmentQuery(environment),
...kqlQuery(kuery),
],
},
},
aggs: {
containerIds: {
terms: {
field: CONTAINER_ID,
size: 500,
},
},
hostNames: {
terms: {
field: HOST_HOSTNAME,
size: 500,
},
},
podNames: {
terms: {
field: POD_NAME,
size: 500,
},
},
},
},
});
return {
containerIds:
response.aggregations?.containerIds?.buckets.map(
(bucket) => bucket.key as string
) ?? [],
hostNames:
response.aggregations?.hostNames?.buckets.map(
(bucket) => bucket.key as string
) ?? [],
podNames:
response.aggregations?.podNames?.buckets.map(
(bucket) => bucket.key as string
) ?? [],
};
};

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { setupRequest } from '../../lib/helpers/setup_request';
import { environmentRt, kueryRt, rangeRt } from '../default_api_types';
import { getInfrastructureData } from './get_infrastructure_data';
import { getContainerHostNames } from './get_host_names';
const infrastructureRoute = createApmServerRoute({
endpoint:
'GET /internal/apm/services/{serviceName}/infrastructure_attributes',
params: t.type({
path: t.type({
serviceName: t.string,
}),
query: t.intersection([kueryRt, rangeRt, environmentRt]),
}),
options: { tags: ['access:apm'] },
handler: async (
resources
): Promise<{
containerIds: string[];
hostNames: string[];
podNames: string[];
}> => {
const setup = await setupRequest(resources);
const {
context,
params,
plugins: { infra },
} = resources;
const {
path: { serviceName },
query: { environment, kuery, start, end },
} = params;
const infrastructureData = await getInfrastructureData({
setup,
serviceName,
environment,
kuery,
start,
end,
});
const containerIds = infrastructureData.containerIds;
// due some limitations on the data we get from apm-metrics indices, if we have a service running in a container we want to query, to get the host.name, filtering by container.id
const containerHostNames = await getContainerHostNames({
containerIds,
context,
infra,
start,
end,
});
return {
containerIds,
hostNames:
containerIds.length > 0 // if we have container ids we rely on the hosts fetched filtering by container.id
? containerHostNames
: infrastructureData.hostNames,
podNames: infrastructureData.podNames,
};
},
});
export const infrastructureRouteRepository = {
...infrastructureRoute,
};

View file

@ -1118,8 +1118,10 @@ const serviceProfilingStatisticsRoute = createApmServerRoute({
},
});
// TODO: remove this endpoint in favour of
const serviceInfrastructureRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/services/{serviceName}/infrastructure',
endpoint:
'GET /internal/apm/services/{serviceName}/infrastructure_attributes_for_logs',
params: t.type({
path: t.type({
serviceName: t.string,

View file

@ -31,3 +31,4 @@ export type InfraAppId = 'logs' | 'metrics';
// Shared components
export { LazyLogStreamWrapper as LogStream } from './components/log_stream/lazy_log_stream_wrapper';
export type { LogStreamProps } from './components/log_stream';
export type { InfraClientStartExports } from './types';

View file

@ -7478,7 +7478,6 @@
"xpack.apm.home.infraTabLabel": "Infrastructure",
"xpack.apm.home.serviceLogsTabLabel": "Logs",
"xpack.apm.home.serviceMapTabLabel": "Carte des services",
"xpack.apm.infra.announcement": "Données sur les infrastructures bientôt disponibles",
"xpack.apm.inspectButtonText": "Inspecter",
"xpack.apm.instancesLatencyDistributionChartLegend": "Instances",
"xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "Période précédente",

View file

@ -7574,7 +7574,6 @@
"xpack.apm.home.infraTabLabel": "インフラストラクチャー",
"xpack.apm.home.serviceLogsTabLabel": "ログ",
"xpack.apm.home.serviceMapTabLabel": "サービスマップ",
"xpack.apm.infra.announcement": "インフラストラクチャデータは近日公開予定です",
"xpack.apm.inspectButtonText": "検査",
"xpack.apm.instancesLatencyDistributionChartLegend": "インスタンス",
"xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "前の期間",

View file

@ -7591,7 +7591,6 @@
"xpack.apm.home.infraTabLabel": "基础设施",
"xpack.apm.home.serviceLogsTabLabel": "日志",
"xpack.apm.home.serviceMapTabLabel": "服务地图",
"xpack.apm.infra.announcement": "即将提供基础架构数据",
"xpack.apm.inspectButtonText": "检查",
"xpack.apm.instancesLatencyDistributionChartLegend": "实例",
"xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "上一时段",