[APM] Display kubernetes metadata in service icons popup and node instance accordion (#139612)

* Extend endpoints to include containers metadata info

* Display kubernetes metadata in icons popover

* Display kubernetes metadata in node instance accordion

* Fix translations

* Fix types

* Fix unit tests

* Fix import order

* Update storybooks

* Hide labels if fields are empty

* Display OS field and remove labels

* CSS tweaks for aligning fields

* Clean up types

* Add API test for kubernetes metadata

* Reword showFilterByOption to isFilterable

* Use top_metrics aggs for kubernetes and container metadata

* Rename getMetricIndices to getInfraMetricIndices

* Fix lint errors

* Fetching metadata of specific container

* Clean up code

* Service metrics on inventory page

* specify size

* Clarify the type of metric

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Katerina Patticha 2022-09-19 15:10:22 +02:00 committed by GitHub
parent cf4312658c
commit 9673ead47b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 18439 additions and 143 deletions

View file

@ -37,8 +37,12 @@ exports[`Error CLOUD_REGION 1`] = `"europe-west1"`;
exports[`Error CLOUD_SERVICE_NAME 1`] = `undefined`;
exports[`Error CONTAINER 1`] = `undefined`;
exports[`Error CONTAINER_ID 1`] = `undefined`;
exports[`Error CONTAINER_IMAGE 1`] = `undefined`;
exports[`Error DESTINATION_ADDRESS 1`] = `undefined`;
exports[`Error ERROR_CULPRIT 1`] = `"handleOopsie"`;
@ -93,6 +97,24 @@ exports[`Error INDEX 1`] = `undefined`;
exports[`Error KUBERNETES 1`] = `undefined`;
exports[`Error KUBERNETES_CONTAINER_NAME 1`] = `undefined`;
exports[`Error KUBERNETES_DEPLOYMENT 1`] = `undefined`;
exports[`Error KUBERNETES_DEPLOYMENT_NAME 1`] = `undefined`;
exports[`Error KUBERNETES_NAMESPACE 1`] = `undefined`;
exports[`Error KUBERNETES_NAMESPACE_NAME 1`] = `undefined`;
exports[`Error KUBERNETES_POD_NAME 1`] = `undefined`;
exports[`Error KUBERNETES_POD_UID 1`] = `undefined`;
exports[`Error KUBERNETES_REPLICASET 1`] = `undefined`;
exports[`Error KUBERNETES_REPLICASET_NAME 1`] = `undefined`;
exports[`Error LABEL_NAME 1`] = `undefined`;
exports[`Error METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`;
@ -133,8 +155,6 @@ exports[`Error OBSERVER_LISTENING 1`] = `undefined`;
exports[`Error PARENT_ID 1`] = `"parentId"`;
exports[`Error POD_NAME 1`] = `undefined`;
exports[`Error PROCESSOR_EVENT 1`] = `"error"`;
exports[`Error SERVICE 1`] = `
@ -266,8 +286,12 @@ exports[`Span CLOUD_REGION 1`] = `"europe-west1"`;
exports[`Span CLOUD_SERVICE_NAME 1`] = `undefined`;
exports[`Span CONTAINER 1`] = `undefined`;
exports[`Span CONTAINER_ID 1`] = `undefined`;
exports[`Span CONTAINER_IMAGE 1`] = `undefined`;
exports[`Span DESTINATION_ADDRESS 1`] = `undefined`;
exports[`Span ERROR_CULPRIT 1`] = `undefined`;
@ -318,6 +342,24 @@ exports[`Span INDEX 1`] = `undefined`;
exports[`Span KUBERNETES 1`] = `undefined`;
exports[`Span KUBERNETES_CONTAINER_NAME 1`] = `undefined`;
exports[`Span KUBERNETES_DEPLOYMENT 1`] = `undefined`;
exports[`Span KUBERNETES_DEPLOYMENT_NAME 1`] = `undefined`;
exports[`Span KUBERNETES_NAMESPACE 1`] = `undefined`;
exports[`Span KUBERNETES_NAMESPACE_NAME 1`] = `undefined`;
exports[`Span KUBERNETES_POD_NAME 1`] = `undefined`;
exports[`Span KUBERNETES_POD_UID 1`] = `undefined`;
exports[`Span KUBERNETES_REPLICASET 1`] = `undefined`;
exports[`Span KUBERNETES_REPLICASET_NAME 1`] = `undefined`;
exports[`Span LABEL_NAME 1`] = `undefined`;
exports[`Span METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`;
@ -358,8 +400,6 @@ exports[`Span OBSERVER_LISTENING 1`] = `undefined`;
exports[`Span PARENT_ID 1`] = `"parentId"`;
exports[`Span POD_NAME 1`] = `undefined`;
exports[`Span PROCESSOR_EVENT 1`] = `"span"`;
exports[`Span SERVICE 1`] = `
@ -487,8 +527,16 @@ exports[`Transaction CLOUD_REGION 1`] = `"europe-west1"`;
exports[`Transaction CLOUD_SERVICE_NAME 1`] = `undefined`;
exports[`Transaction CONTAINER 1`] = `
Object {
"id": "container1234567890abcdef",
}
`;
exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`;
exports[`Transaction CONTAINER_IMAGE 1`] = `undefined`;
exports[`Transaction DESTINATION_ADDRESS 1`] = `undefined`;
exports[`Transaction ERROR_CULPRIT 1`] = `undefined`;
@ -549,6 +597,24 @@ Object {
}
`;
exports[`Transaction KUBERNETES_CONTAINER_NAME 1`] = `undefined`;
exports[`Transaction KUBERNETES_DEPLOYMENT 1`] = `undefined`;
exports[`Transaction KUBERNETES_DEPLOYMENT_NAME 1`] = `undefined`;
exports[`Transaction KUBERNETES_NAMESPACE 1`] = `undefined`;
exports[`Transaction KUBERNETES_NAMESPACE_NAME 1`] = `undefined`;
exports[`Transaction KUBERNETES_POD_NAME 1`] = `undefined`;
exports[`Transaction KUBERNETES_POD_UID 1`] = `"pod1234567890abcdef"`;
exports[`Transaction KUBERNETES_REPLICASET 1`] = `undefined`;
exports[`Transaction KUBERNETES_REPLICASET_NAME 1`] = `undefined`;
exports[`Transaction LABEL_NAME 1`] = `undefined`;
exports[`Transaction METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`;
@ -589,8 +655,6 @@ exports[`Transaction OBSERVER_LISTENING 1`] = `undefined`;
exports[`Transaction PARENT_ID 1`] = `"parentId"`;
exports[`Transaction POD_NAME 1`] = `undefined`;
exports[`Transaction PROCESSOR_EVENT 1`] = `"transaction"`;
exports[`Transaction SERVICE 1`] = `

View file

@ -121,8 +121,20 @@ export const HOST_HOSTNAME = 'host.hostname'; // Do not use. Please use `HOST_NA
export const HOST_NAME = 'host.name';
export const HOST_OS_PLATFORM = 'host.os.platform';
export const CONTAINER_ID = 'container.id';
export const CONTAINER = 'container';
export const CONTAINER_IMAGE = 'container.image.name';
// Kubernetes
export const KUBERNETES = 'kubernetes';
export const POD_NAME = 'kubernetes.pod.name';
export const KUBERNETES_CONTAINER_NAME = 'kubernetes.container.name';
export const KUBERNETES_DEPLOYMENT = 'kubernetes.deployment';
export const KUBERNETES_DEPLOYMENT_NAME = 'kubernetes.deployment.name';
export const KUBERNETES_NAMESPACE_NAME = 'kubernetes.namespace.name';
export const KUBERNETES_NAMESPACE = 'kubernetes.namespace';
export const KUBERNETES_POD_NAME = 'kubernetes.pod.name';
export const KUBERNETES_POD_UID = 'kubernetes.pod.uid';
export const KUBERNETES_REPLICASET = 'kubernetes.replicaset';
export const KUBERNETES_REPLICASET_NAME = 'kubernetes.replicaset.name';
export const CLIENT_GEO_COUNTRY_ISO_CODE = 'client.geo.country_iso_code';

View file

@ -18,12 +18,18 @@ import {
CLOUD_PROVIDER,
CONTAINER_ID,
HOST_NAME,
POD_NAME,
SERVICE_NODE_NAME,
SERVICE_RUNTIME_NAME,
SERVICE_RUNTIME_VERSION,
SERVICE_VERSION,
KUBERNETES_CONTAINER_NAME,
KUBERNETES_NAMESPACE,
KUBERNETES_POD_NAME,
KUBERNETES_POD_UID,
KUBERNETES_REPLICASET_NAME,
KUBERNETES_DEPLOYMENT_NAME,
} from '../../../../../common/elasticsearch_fieldnames';
import { FETCH_STATUS } from '../../../../hooks/use_fetcher';
import { useTheme } from '../../../../hooks/use_theme';
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
@ -42,8 +48,20 @@ interface Props {
kuery: string;
}
function toKeyValuePairs(keys: string[], data: ServiceInstanceDetails) {
return keys.map((key) => ({ key, value: get(data, key) }));
function toKeyValuePairs({
keys,
data,
isFilterable = true,
}: {
keys: string[];
data: ServiceInstanceDetails;
isFilterable?: boolean;
}) {
return keys.map((key) => ({
key,
value: get(data, key),
isFilterable,
}));
}
const serviceDetailsKeys = [
@ -52,7 +70,18 @@ const serviceDetailsKeys = [
SERVICE_RUNTIME_NAME,
SERVICE_RUNTIME_VERSION,
];
const containerDetailsKeys = [CONTAINER_ID, HOST_NAME, POD_NAME];
const containerDetailsKeys = [
CONTAINER_ID,
HOST_NAME,
KUBERNETES_POD_UID,
KUBERNETES_POD_NAME,
];
const metricsKubernetesDetailsKeys = [
KUBERNETES_CONTAINER_NAME,
KUBERNETES_NAMESPACE,
KUBERNETES_REPLICASET_NAME,
KUBERNETES_DEPLOYMENT_NAME,
];
const cloudDetailsKeys = [
CLOUD_AVAILABILITY_ZONE,
CLOUD_INSTANCE_ID,
@ -93,12 +122,23 @@ export function InstanceDetails({
pushNewItemToKueryBar({ kuery, history, key, value });
};
const serviceDetailsKeyValuePairs = toKeyValuePairs(serviceDetailsKeys, data);
const containerDetailsKeyValuePairs = toKeyValuePairs(
containerDetailsKeys,
data
);
const cloudDetailsKeyValuePairs = toKeyValuePairs(cloudDetailsKeys, data);
const serviceDetailsKeyValuePairs = toKeyValuePairs({
keys: serviceDetailsKeys,
data,
});
const containerDetailsKeyValuePairs = toKeyValuePairs({
keys: containerDetailsKeys,
data,
});
const metricsKubernetesKeyValuePairs = toKeyValuePairs({
keys: metricsKubernetesDetailsKeys,
data,
isFilterable: false,
});
const cloudDetailsKeyValuePairs = toKeyValuePairs({
keys: cloudDetailsKeys,
data,
});
const containerType = data.kubernetes?.pod?.name ? 'Kubernetes' : 'Docker';
return (
@ -122,7 +162,10 @@ export function InstanceDetails({
{ defaultMessage: 'Container' }
)}
icon={getContainerIcon(containerType)}
keyValueList={containerDetailsKeyValuePairs}
keyValueList={[
...containerDetailsKeyValuePairs,
...metricsKubernetesKeyValuePairs,
]}
onClickFilter={addKueryBarFilter}
/>
</EuiFlexItem>

View file

@ -19,10 +19,12 @@ import {
import { i18n } from '@kbn/i18n';
import React, { Fragment } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash';
interface KeyValue {
key: string;
value: any | undefined;
isFilterable: boolean;
}
const StyledEuiAccordion = styled(EuiAccordion)`
@ -43,13 +45,8 @@ const StyledEuiDescriptionList = styled(EuiDescriptionList)`
display: flex;
`;
const ValueContainer = styled.div`
display: flex;
align-items: center;
`;
function removeEmptyValues(items: KeyValue[]) {
return items.filter(({ value }) => value !== undefined);
return items.filter(({ value }) => !isEmpty(value));
}
export function KeyValueFilterList({
@ -78,12 +75,12 @@ export function KeyValueFilterList({
buttonClassName="buttonContentContainer"
>
<StyledEuiDescriptionList type="column">
{nonEmptyKeyValueList.map(({ key, value }) => {
{nonEmptyKeyValueList.map(({ key, value, isFilterable }) => {
return (
<Fragment key={key}>
<EuiDescriptionListTitle
className="descriptionList__title"
style={{ width: '20%' }}
style={{ width: '20%', height: '40px' }}
>
<EuiText size="s" style={{ fontWeight: 'bold' }}>
{key}
@ -91,27 +88,37 @@ export function KeyValueFilterList({
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="descriptionList__description"
style={{ width: '80%' }}
style={{ width: '80%', height: '40px' }}
>
<ValueContainer>
<EuiButtonEmpty
onClick={() => {
onClickFilter({ key, value });
}}
data-test-subj={`filter_by_${key}`}
>
<EuiToolTip
position="top"
content={i18n.translate(
'xpack.apm.keyValueFilterList.actionFilterLabel',
{ defaultMessage: 'Filter by value' }
)}
>
<EuiIcon type="filter" color="text" size="m" />
</EuiToolTip>
</EuiButtonEmpty>
<EuiText size="s">{value}</EuiText>
</ValueContainer>
<EuiFlexGroup
alignItems="baseline"
responsive={false}
gutterSize="none"
>
<EuiFlexItem style={{ minWidth: '32px' }} grow={false}>
{isFilterable && (
<EuiButtonEmpty
onClick={() => {
onClickFilter({ key, value });
}}
data-test-subj={`filter_by_${key}`}
>
<EuiToolTip
position="top"
content={i18n.translate(
'xpack.apm.keyValueFilterList.actionFilterLabel',
{ defaultMessage: 'Filter by value' }
)}
>
<EuiIcon type="filter" color="text" size="m" />
</EuiToolTip>
</EuiButtonEmpty>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">{value}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionListDescription>
</Fragment>
);

View file

@ -28,8 +28,8 @@ describe('KeyValueFilterList', () => {
<KeyValueFilterList
title="title"
keyValueList={[
{ key: 'foo', value: 'foo value' },
{ key: 'bar', value: 'bar value' },
{ key: 'foo', value: 'foo value', isFilterable: true },
{ key: 'bar', value: 'bar value', isFilterable: true },
]}
onClickFilter={jest.fn}
/>
@ -48,8 +48,8 @@ describe('KeyValueFilterList', () => {
title="title"
icon="alert"
keyValueList={[
{ key: 'foo', value: 'foo value' },
{ key: 'bar', value: 'bar value' },
{ key: 'foo', value: 'foo value', isFilterable: true },
{ key: 'bar', value: 'bar value', isFilterable: true },
]}
onClickFilter={jest.fn}
/>
@ -62,8 +62,8 @@ describe('KeyValueFilterList', () => {
<KeyValueFilterList
title="title"
keyValueList={[
{ key: 'foo', value: 'foo value' },
{ key: 'bar', value: 'bar value' },
{ key: 'foo', value: 'foo value', isFilterable: true },
{ key: 'bar', value: 'bar value', isFilterable: true },
]}
onClickFilter={jest.fn}
/>
@ -71,20 +71,38 @@ describe('KeyValueFilterList', () => {
expect(component.queryAllByTestId('accordion_title_icon')).toEqual([]);
expectTextsInDocument(component, ['title']);
});
it('hides filter by value option', () => {
const component = renderWithTheme(
<KeyValueFilterList
title="title"
keyValueList={[
{ key: 'foo', value: 'foo value', isFilterable: false },
{ key: 'bar', value: 'bar value', isFilterable: true },
]}
onClickFilter={jest.fn}
/>
);
expect(component.queryAllByTestId('filter_by_foo')).toEqual([]);
expect(component.queryAllByTestId('filter_by_bar')).toHaveLength(1);
});
it('returns selected key value when the filter button is clicked', () => {
const mockFilter = jest.fn();
const component = renderWithTheme(
<KeyValueFilterList
title="title"
keyValueList={[
{ key: 'foo', value: 'foo value' },
{ key: 'bar', value: 'bar value' },
{ key: 'foo', value: 'foo value', isFilterable: true },
{ key: 'bar', value: 'bar value', isFilterable: true },
]}
onClickFilter={mockFilter}
/>
);
fireEvent.click(component.getByTestId('filter_by_foo'));
expect(mockFilter).toHaveBeenCalledWith({ key: 'foo', value: 'foo value' });
expect(mockFilter).toHaveBeenCalledWith({
key: 'foo',
value: 'foo value',
});
});
});

View file

@ -5,7 +5,11 @@
* 2.0.
*/
import { EuiDescriptionList, EuiDescriptionListProps } from '@elastic/eui';
import {
EuiDescriptionList,
EuiDescriptionListProps,
EuiBadge,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { asInteger } from '../../../../common/utils/formatters';
@ -16,18 +20,38 @@ type ServiceDetailsReturnType =
interface Props {
container: ServiceDetailsReturnType['container'];
kubernetes: ServiceDetailsReturnType['kubernetes'];
}
export function ContainerDetails({ container }: Props) {
export function ContainerDetails({ container, kubernetes }: Props) {
if (!container) {
return null;
}
const listItems: EuiDescriptionListProps['listItems'] = [];
if (kubernetes?.containerImages && kubernetes?.containerImages.length > 0) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.image.name',
{ defaultMessage: 'Container images' }
),
description: (
<ul>
{kubernetes.containerImages.map((deployment, index) => (
<li key={index}>
<EuiBadge color="hollow">{deployment}</EuiBadge>
</li>
))}
</ul>
),
});
}
if (container.os) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.osLabel',
'xpack.apm.serviceIcons.serviceDetails.container.os.label',
{
defaultMessage: 'OS',
}
@ -36,25 +60,57 @@ export function ContainerDetails({ container }: Props) {
});
}
if (container.isContainerized !== undefined) {
if (kubernetes?.deployments && kubernetes?.deployments.length > 0) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.containerizedLabel',
{ defaultMessage: 'Containerized' }
'xpack.apm.serviceIcons.serviceDetails.kubernetes.deployments',
{ defaultMessage: 'Deployments' }
),
description: (
<ul>
{kubernetes.deployments.map((deployment, index) => (
<li key={index}>
<EuiBadge color="hollow">{deployment}</EuiBadge>
</li>
))}
</ul>
),
});
}
if (kubernetes?.namespaces && kubernetes?.namespaces.length > 0) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.kubernetes.namespaces',
{ defaultMessage: 'Namespaces' }
),
description: (
<ul>
{kubernetes.namespaces.map((namespace, index) => (
<li key={index}>
<EuiBadge color="hollow">{namespace}</EuiBadge>
</li>
))}
</ul>
),
});
}
if (kubernetes?.replicasets && kubernetes?.replicasets.length > 0) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.kubernetes.replicasets',
{ defaultMessage: 'Replicasets' }
),
description: (
<ul>
{kubernetes.replicasets.map((replicaset, index) => (
<li key={index}>
<EuiBadge color="hollow">{replicaset}</EuiBadge>
</li>
))}
</ul>
),
description: container.isContainerized
? i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.yesLabel',
{
defaultMessage: 'Yes',
}
)
: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.noLabel',
{
defaultMessage: 'No',
}
),
});
}
@ -68,15 +124,5 @@ export function ContainerDetails({ container }: Props) {
});
}
if (container.type) {
listItems.push({
title: i18n.translate(
'xpack.apm.serviceIcons.serviceDetails.container.orchestrationLabel',
{ defaultMessage: 'Orchestration' }
),
description: container.type,
});
}
return <EuiDescriptionList textStyle="reverse" listItems={listItems} />;
}

View file

@ -129,7 +129,12 @@ export function ServiceIcons({ start, end, serviceName }: Props) {
title: i18n.translate('xpack.apm.serviceIcons.container', {
defaultMessage: 'Container',
}),
component: <ContainerDetails container={details?.container} />,
component: (
<ContainerDetails
container={details?.container}
kubernetes={details?.kubernetes}
/>
),
},
{
key: 'serverless',

View file

@ -115,10 +115,13 @@ Example.args = {
},
},
container: {
os: 'Linux',
type: 'Kubernetes',
isContainerized: true,
totalNumberInstances: 1,
image: 'container image name',
},
kubernetes: {
deployments: ['opbeans-java', 'opbeans-go-nsn'],
replicasets: ['opbeans-go-6dff977956', 'opbeans-go-nsn-864bdcbc5b'],
namespaces: ['default'],
},
cloud: {
provider: 'gcp',

View file

@ -18,7 +18,7 @@ import {
TRANSACTION_TYPE,
AGENT_NAME,
SERVICE_ENVIRONMENT,
POD_NAME,
KUBERNETES_POD_NAME,
CONTAINER_ID,
SERVICE_VERSION,
TRANSACTION_RESULT,
@ -91,7 +91,7 @@ export async function aggregateLatencyMetrics() {
SERVICE_ENVIRONMENT,
AGENT_NAME,
HOST_NAME,
POD_NAME,
KUBERNETES_POD_NAME,
CONTAINER_ID,
TRANSACTION_NAME,
TRANSACTION_RESULT,

View file

@ -30,7 +30,7 @@ import {
HOST_OS_PLATFORM,
OBSERVER_HOSTNAME,
PARENT_ID,
POD_NAME,
KUBERNETES_POD_NAME,
PROCESSOR_EVENT,
SERVICE_ENVIRONMENT,
SERVICE_FRAMEWORK_NAME,
@ -180,7 +180,7 @@ export const tasks: TelemetryTask[] = [
SERVICE_VERSION,
HOST_NAME,
CONTAINER_ID,
POD_NAME,
KUBERNETES_POD_NAME,
].map((field) => ({ terms: { field, missing_bucket: true } }));
const observerHostname = {
@ -1206,7 +1206,7 @@ export const tasks: TelemetryTask[] = [
field: SERVICE_RUNTIME_VERSION,
},
{
field: POD_NAME,
field: KUBERNETES_POD_NAME,
},
{
field: CONTAINER_ID,
@ -1314,7 +1314,7 @@ export const tasks: TelemetryTask[] = [
kubernetes: {
pod: {
name: serviceBucket.top_metrics?.top[0].metrics[
POD_NAME
KUBERNETES_POD_NAME
] as string,
},
},

View file

@ -8,7 +8,7 @@
import { SavedObjectsClientContract } from '@kbn/core/server';
import { APMRouteHandlerResources } from '../../routes/typings';
export async function getMetricIndices({
export async function getInfraMetricIndices({
infraPlugin,
savedObjectsClient,
}: {
@ -16,7 +16,7 @@ export async function getMetricIndices({
savedObjectsClient: SavedObjectsClientContract;
}): Promise<string> {
const infra = await infraPlugin.start();
const metricIndices = await infra.getMetricIndices(savedObjectsClient);
const infraMetricIndices = await infra.getMetricIndices(savedObjectsClient);
return metricIndices;
return infraMetricIndices;
}

View file

@ -14,7 +14,7 @@ import {
HOST_NAME,
} from '../../../common/elasticsearch_fieldnames';
import { ApmPluginRequestHandlerContext } from '../typings';
import { getMetricIndices } from '../../lib/helpers/get_metric_indices';
import { getInfraMetricIndices } from '../../lib/helpers/get_infra_metric_indices';
interface Aggs extends estypes.AggregationsMultiBucketAggregateBase {
buckets: Array<{
@ -92,7 +92,7 @@ export const getContainerHostNames = async ({
if (containerIds.length) {
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const savedObjectsClient = (await context.core).savedObjects.client;
const metricIndices = await getMetricIndices({
const metricIndices = await getInfraMetricIndices({
infraPlugin: infra,
savedObjectsClient,
});

View file

@ -13,7 +13,7 @@ import {
SERVICE_NAME,
CONTAINER_ID,
HOST_HOSTNAME,
POD_NAME,
KUBERNETES_POD_NAME,
} from '../../../common/elasticsearch_fieldnames';
export const getInfrastructureData = async ({
@ -64,7 +64,7 @@ export const getInfrastructureData = async ({
},
podNames: {
terms: {
field: POD_NAME,
field: KUBERNETES_POD_NAME,
size: 500,
},
},

View file

@ -0,0 +1,100 @@
/*
* 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 {
CONTAINER,
CONTAINER_ID,
CONTAINER_IMAGE,
KUBERNETES,
KUBERNETES_CONTAINER_NAME,
KUBERNETES_NAMESPACE,
KUBERNETES_POD_NAME,
KUBERNETES_POD_UID,
KUBERNETES_REPLICASET_NAME,
KUBERNETES_DEPLOYMENT_NAME,
} from '../../../common/elasticsearch_fieldnames';
import { Kubernetes } from '../../../typings/es_schemas/raw/fields/kubernetes';
import { maybe } from '../../../common/utils/maybe';
type ServiceInstanceContainerMetadataDetails =
| {
kubernetes: Kubernetes;
}
| undefined;
export const getServiceInstanceContainerMetadata = async ({
esClient,
indexName,
containerId,
start,
end,
}: {
esClient: ElasticsearchClient;
indexName?: string;
containerId: string;
start: number;
end: number;
}): Promise<ServiceInstanceContainerMetadataDetails> => {
if (!indexName) {
return undefined;
}
const should = [
{ exists: { field: KUBERNETES } },
{ exists: { field: CONTAINER_IMAGE } },
{ exists: { field: KUBERNETES_CONTAINER_NAME } },
{ exists: { field: KUBERNETES_NAMESPACE } },
{ exists: { field: KUBERNETES_POD_NAME } },
{ exists: { field: KUBERNETES_POD_UID } },
{ exists: { field: KUBERNETES_REPLICASET_NAME } },
{ exists: { field: KUBERNETES_DEPLOYMENT_NAME } },
];
const response = await esClient.search<unknown>({
index: [indexName],
_source: [KUBERNETES, CONTAINER],
size: 1,
query: {
bool: {
filter: [
{
term: {
[CONTAINER_ID]: containerId,
},
},
...rangeQuery(start, end),
],
should,
},
},
});
const sample = maybe(response.hits.hits[0])
?._source as ServiceInstanceContainerMetadataDetails;
return {
kubernetes: {
pod: {
name: sample?.kubernetes?.pod?.name,
uid: sample?.kubernetes?.pod?.uid,
},
deployment: {
name: sample?.kubernetes?.deployment?.name,
},
replicaset: {
name: sample?.kubernetes?.replicaset?.name,
},
namespace: sample?.kubernetes?.namespace,
container: {
name: sample?.kubernetes?.container?.name,
id: sample?.kubernetes?.container?.id,
},
},
};
};

View file

@ -9,6 +9,7 @@ import { rangeQuery } from '@kbn/observability-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import {
AGENT,
CONTAINER,
CLOUD,
CLOUD_AVAILABILITY_ZONE,
CLOUD_REGION,
@ -49,10 +50,10 @@ export interface ServiceMetadataDetails {
};
};
container?: {
ids?: string[];
image?: string;
os?: string;
isContainerized?: boolean;
totalNumberInstances?: number;
type?: ContainerType;
};
serverless?: {
type?: string;
@ -67,6 +68,12 @@ export interface ServiceMetadataDetails {
projectName?: string;
serviceName?: string;
};
kubernetes?: {
deployments?: string[];
namespaces?: string[];
replicasets?: string[];
containerImages?: string[];
};
}
export async function getServiceMetadataDetails({
@ -99,7 +106,7 @@ export async function getServiceMetadataDetails({
},
body: {
size: 1,
_source: [SERVICE, AGENT, HOST, CONTAINER_ID, KUBERNETES, CLOUD],
_source: [SERVICE, AGENT, HOST, CONTAINER, KUBERNETES, CLOUD],
query: { bool: { filter, should } },
aggs: {
serviceVersions: {
@ -115,6 +122,12 @@ export async function getServiceMetadataDetails({
size: 10,
},
},
containerIds: {
terms: {
field: CONTAINER_ID,
size: 10,
},
},
regions: {
terms: {
field: CLOUD_REGION,
@ -181,10 +194,12 @@ export async function getServiceMetadataDetails({
const containerDetails =
host || container || totalNumberInstances || kubernetes
? {
os: host?.os?.platform,
type: (!!kubernetes ? 'Kubernetes' : 'Docker') as ContainerType,
isContainerized: !!container?.id,
os: host?.os?.platform,
totalNumberInstances,
ids: response.aggregations?.containerIds.buckets.map(
(bucket) => bucket.key as string
),
}
: undefined;

View file

@ -14,7 +14,7 @@ import {
CONTAINER_ID,
KUBERNETES,
SERVICE_NAME,
POD_NAME,
KUBERNETES_POD_NAME,
HOST_OS_PLATFORM,
} from '../../../common/elasticsearch_fieldnames';
import { ContainerType } from '../../../common/service_metadata';
@ -36,7 +36,7 @@ export interface ServiceMetadataIcons {
export const should = [
{ exists: { field: CONTAINER_ID } },
{ exists: { field: POD_NAME } },
{ exists: { field: KUBERNETES_POD_NAME } },
{ exists: { field: CLOUD_PROVIDER } },
{ exists: { field: HOST_OS_PLATFORM } },
{ exists: { field: AGENT_NAME } },

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient } from '@kbn/core/server';
import { rangeQuery } from '@kbn/observability-plugin/server';
import {
CONTAINER,
CONTAINER_ID,
CONTAINER_IMAGE,
KUBERNETES,
KUBERNETES_CONTAINER_NAME,
KUBERNETES_NAMESPACE,
KUBERNETES_POD_NAME,
KUBERNETES_POD_UID,
KUBERNETES_REPLICASET_NAME,
KUBERNETES_DEPLOYMENT_NAME,
} from '../../../common/elasticsearch_fieldnames';
type ServiceOverviewContainerMetadataDetails =
| {
kubernetes: {
deployments?: string[];
replicasets?: string[];
namespaces?: string[];
containerImages?: string[];
};
}
| undefined;
interface ResponseAggregations {
[key: string]: {
buckets: Array<{
key: string;
}>;
};
}
export const getServiceOverviewContainerMetadata = async ({
esClient,
indexName,
containerIds,
start,
end,
}: {
esClient: ElasticsearchClient;
indexName?: string;
containerIds: string[];
start: number;
end: number;
}): Promise<ServiceOverviewContainerMetadataDetails> => {
if (!indexName) {
return undefined;
}
const should = [
{ exists: { field: KUBERNETES } },
{ exists: { field: CONTAINER_IMAGE } },
{ exists: { field: KUBERNETES_CONTAINER_NAME } },
{ exists: { field: KUBERNETES_NAMESPACE } },
{ exists: { field: KUBERNETES_POD_NAME } },
{ exists: { field: KUBERNETES_POD_UID } },
{ exists: { field: KUBERNETES_REPLICASET_NAME } },
{ exists: { field: KUBERNETES_DEPLOYMENT_NAME } },
];
const response = await esClient.search<unknown, ResponseAggregations>({
index: [indexName],
_source: [KUBERNETES, CONTAINER],
size: 0,
query: {
bool: {
filter: [
{
terms: {
[CONTAINER_ID]: containerIds,
},
},
...rangeQuery(start, end),
],
should,
},
},
aggs: {
deployments: {
terms: {
field: KUBERNETES_DEPLOYMENT_NAME,
size: 10,
},
},
namespaces: {
terms: {
field: KUBERNETES_NAMESPACE,
size: 10,
},
},
replicasets: {
terms: {
field: KUBERNETES_REPLICASET_NAME,
size: 10,
},
},
containerImages: {
terms: {
field: CONTAINER_IMAGE,
size: 10,
},
},
},
});
return {
kubernetes: {
deployments: response.aggregations?.deployments?.buckets.map(
(bucket) => bucket.key
),
replicasets: response.aggregations?.replicasets?.buckets.map(
(bucket) => bucket.key
),
namespaces: response.aggregations?.namespaces?.buckets.map(
(bucket) => bucket.key
),
containerImages: response.aggregations?.containerImages?.buckets.map(
(bucket) => bucket.key
),
},
};
};

View file

@ -8,7 +8,7 @@
import Boom from '@hapi/boom';
import { isoToEpochRt, jsonRt, toNumberRt } from '@kbn/io-ts-utils';
import * as t from 'io-ts';
import { uniq } from 'lodash';
import { uniq, mergeWith } from 'lodash';
import {
UnknownMLCapabilitiesError,
InsufficientMLCapabilities,
@ -40,6 +40,8 @@ import {
probabilityRt,
} from '../default_api_types';
import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate';
import { getServiceOverviewContainerMetadata } from './get_service_overview_container_metadata';
import { getServiceInstanceContainerMetadata } from './get_service_instance_container_metadata';
import { getServicesDetailedStatistics } from './get_services_detailed_statistics';
import { getServiceDependenciesBreakdown } from './get_service_dependencies_breakdown';
import { getAnomalyTimeseries } from '../../lib/anomaly_detection/get_anomaly_timeseries';
@ -51,7 +53,7 @@ import { ServiceHealthStatus } from '../../../common/service_health_status';
import { getServiceGroup } from '../service_groups/get_service_group';
import { offsetRt } from '../../../common/comparison_rt';
import { getRandomSampler } from '../../lib/helpers/get_random_sampler';
import { getInfraMetricIndices } from '../../lib/helpers/get_infra_metric_indices';
const servicesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/services',
params: t.type({
@ -249,7 +251,7 @@ const serviceMetadataDetailsRoute = createApmServerRoute({
import('./get_service_metadata_details').ServiceMetadataDetails
> => {
const setup = await setupRequest(resources);
const { params } = resources;
const { params, context, plugins } = resources;
const { serviceName } = params.path;
const { start, end } = params.query;
@ -261,13 +263,37 @@ const serviceMetadataDetailsRoute = createApmServerRoute({
kuery: '',
});
return getServiceMetadataDetails({
const serviceMetadataDetails = await getServiceMetadataDetails({
serviceName,
setup,
searchAggregatedTransactions,
start,
end,
});
if (serviceMetadataDetails?.container?.ids) {
const {
savedObjects: { client: savedObjectsClient },
elasticsearch: { client: esClient },
} = await context.core;
const indexName = await getInfraMetricIndices({
infraPlugin: plugins.infra,
savedObjectsClient,
});
const containerMetadata = await getServiceOverviewContainerMetadata({
esClient: esClient.asCurrentUser,
indexName,
containerIds: serviceMetadataDetails.container.ids,
start,
end,
});
return mergeWith(serviceMetadataDetails, containerMetadata);
}
return serviceMetadataDetails;
},
});
@ -862,16 +888,42 @@ export const serviceInstancesMetadataDetails = createApmServerRoute({
| undefined;
}> => {
const setup = await setupRequest(resources);
const { serviceName, serviceNodeName } = resources.params.path;
const { start, end } = resources.params.query;
const { params, context, plugins } = resources;
const { serviceName, serviceNodeName } = params.path;
const { start, end } = params.query;
return await getServiceInstanceMetadataDetails({
setup,
serviceName,
serviceNodeName,
start,
end,
});
const serviceInstanceMetadataDetails =
await getServiceInstanceMetadataDetails({
setup,
serviceName,
serviceNodeName,
start,
end,
});
if (serviceInstanceMetadataDetails?.container?.id) {
const {
savedObjects: { client: savedObjectsClient },
elasticsearch: { client: esClient },
} = await context.core;
const indexName = await getInfraMetricIndices({
infraPlugin: plugins.infra,
savedObjectsClient,
});
const containerMetadata = await getServiceInstanceContainerMetadata({
esClient: esClient.asCurrentUser,
indexName,
containerId: serviceInstanceMetadataDetails.container.id,
start,
end,
});
return mergeWith(serviceInstanceMetadataDetails, containerMetadata);
}
return serviceInstanceMetadataDetails;
},
});

View file

@ -6,5 +6,6 @@
*/
export interface Container {
id: string;
id?: string | null;
image?: string | null;
}

View file

@ -6,5 +6,16 @@
*/
export interface Kubernetes {
pod?: { uid: string; [key: string]: unknown };
pod?: { uid?: string | null; [key: string]: unknown };
namespace?: string;
replicaset?: {
name?: string;
};
deployment?: {
name?: string;
};
container?: {
id?: string;
name?: string;
};
}

View file

@ -7197,12 +7197,7 @@
"xpack.apm.serviceIcons.serviceDetails.cloud.projectIdLabel": "ID de projet",
"xpack.apm.serviceIcons.serviceDetails.cloud.providerLabel": "Fournisseur cloud",
"xpack.apm.serviceIcons.serviceDetails.cloud.serviceNameLabel": "Service Cloud",
"xpack.apm.serviceIcons.serviceDetails.container.containerizedLabel": "Conteneurisé",
"xpack.apm.serviceIcons.serviceDetails.container.noLabel": "Non",
"xpack.apm.serviceIcons.serviceDetails.container.orchestrationLabel": "Orchestration",
"xpack.apm.serviceIcons.serviceDetails.container.osLabel": "Système d'exploitation",
"xpack.apm.serviceIcons.serviceDetails.container.totalNumberInstancesLabel": "Nombre total d'instances",
"xpack.apm.serviceIcons.serviceDetails.container.yesLabel": "Oui",
"xpack.apm.serviceIcons.serviceDetails.service.agentLabel": "Nom et version de l'agent",
"xpack.apm.serviceIcons.serviceDetails.service.frameworkLabel": "Nom du framework",
"xpack.apm.serviceIcons.serviceDetails.service.runtimeLabel": "Nom et version de l'exécution",

View file

@ -7184,12 +7184,7 @@
"xpack.apm.serviceIcons.serviceDetails.cloud.projectIdLabel": "プロジェクト ID",
"xpack.apm.serviceIcons.serviceDetails.cloud.providerLabel": "クラウドプロバイダー",
"xpack.apm.serviceIcons.serviceDetails.cloud.serviceNameLabel": "クラウドサービス",
"xpack.apm.serviceIcons.serviceDetails.container.containerizedLabel": "コンテナー化",
"xpack.apm.serviceIcons.serviceDetails.container.noLabel": "いいえ",
"xpack.apm.serviceIcons.serviceDetails.container.orchestrationLabel": "オーケストレーション",
"xpack.apm.serviceIcons.serviceDetails.container.osLabel": "OS",
"xpack.apm.serviceIcons.serviceDetails.container.totalNumberInstancesLabel": "インスタンスの合計数",
"xpack.apm.serviceIcons.serviceDetails.container.yesLabel": "はい",
"xpack.apm.serviceIcons.serviceDetails.service.agentLabel": "エージェント名・バージョン",
"xpack.apm.serviceIcons.serviceDetails.service.frameworkLabel": "フレームワーク名",
"xpack.apm.serviceIcons.serviceDetails.service.runtimeLabel": "ランタイム名・バージョン",

View file

@ -7198,12 +7198,7 @@
"xpack.apm.serviceIcons.serviceDetails.cloud.projectIdLabel": "项目 ID",
"xpack.apm.serviceIcons.serviceDetails.cloud.providerLabel": "云服务提供商",
"xpack.apm.serviceIcons.serviceDetails.cloud.serviceNameLabel": "云服务",
"xpack.apm.serviceIcons.serviceDetails.container.containerizedLabel": "容器化",
"xpack.apm.serviceIcons.serviceDetails.container.noLabel": "否",
"xpack.apm.serviceIcons.serviceDetails.container.orchestrationLabel": "编排",
"xpack.apm.serviceIcons.serviceDetails.container.osLabel": "OS",
"xpack.apm.serviceIcons.serviceDetails.container.totalNumberInstancesLabel": "实例总数",
"xpack.apm.serviceIcons.serviceDetails.container.yesLabel": "是",
"xpack.apm.serviceIcons.serviceDetails.service.agentLabel": "代理名称和版本",
"xpack.apm.serviceIcons.serviceDetails.service.frameworkLabel": "框架名称",
"xpack.apm.serviceIcons.serviceDetails.service.runtimeLabel": "运行时名称和版本",

View file

@ -10,4 +10,8 @@ export default {
start: '2021-08-03T06:50:15.910Z',
end: '2021-08-03T07:20:15.910Z',
},
infra_metrics_and_apm: {
start: '2019-06-29T02:48:39.386555Z',
end: '2022-06-29T06:46:26Z',
},
};

View file

@ -17,6 +17,7 @@ type ArchiveName =
| 'apm_8.0.0'
| '8.0.0'
| 'metrics_8.0.0'
| 'infra_metrics_and_apm'
| 'ml_8.0.0'
| 'observability_overview'
| 'rum_8.0.0'

View file

@ -87,12 +87,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
});
it('returns correct container details', () => {
const { containerOs } = dataConfig;
expect(body?.container?.isContainerized).to.be(true);
expect(body?.container?.os).to.be(containerOs);
expect(body?.container?.totalNumberInstances).to.be(1);
expect(body?.container?.type).to.be('Kubernetes');
});
it('returns correct serverless details', () => {

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import archives_metadata from '../../../common/fixtures/es_archiver/archives_metadata';
type ServiceOverviewInstanceDetails =
APIReturnType<'GET /internal/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>;
type ServiceDetails = APIReturnType<'GET /internal/apm/services/{serviceName}/metadata/details'>;
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const archiveName = 'infra_metrics_and_apm';
const { start, end } = archives_metadata[archiveName];
registry.when(
'When data is loaded',
{ config: 'basic', archives: ['infra_metrics_and_apm'] },
() => {
describe('fetch service instance', () => {
it('handles empty infra metrics data for a service node', async () => {
const response = await apmApiClient.readUser({
endpoint:
'GET /internal/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}',
params: {
path: {
serviceName: 'opbeans-node',
serviceNodeName: '768120daead4526f5ba3ec583e0b081a19a525843aa5632a5e0b1de3a367f52d',
},
query: {
start,
end,
},
},
});
const body: ServiceOverviewInstanceDetails = response.body;
const status: number = response.status;
expect(status).to.be(200);
expect(body.kubernetes?.pod).to.eql({});
expect(body.kubernetes?.deployment).to.eql({});
expect(body.kubernetes?.replicaset).to.eql({});
expect(body.kubernetes?.container).to.eql({});
});
it('handles kubernetes metadata for a service node', async () => {
const response = await apmApiClient.readUser({
endpoint:
'GET /internal/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}',
params: {
path: {
serviceName: 'opbeans-java',
serviceNodeName: '31651f3c624b81c55dd4633df0b5b9f9ab06b151121b0404ae796632cd1f87ad',
},
query: {
start,
end,
},
},
});
const body: ServiceOverviewInstanceDetails = response.body;
const status: number = response.status;
expect(status).to.be(200);
expect(body.kubernetes?.deployment?.name).to.eql('opbeans-java');
expect(body.kubernetes?.pod?.name).to.eql('opbeans-java-5b5f75d696-5brrb');
expect(body.kubernetes?.pod?.uid).to.eql('798f59e9-b1b2-11e9-9a96-42010a84004d');
expect(body.kubernetes?.namespace).to.eql('default');
expect(body.kubernetes?.replicaset?.name).to.eql('opbeans-java-5b5f75d696');
expect(body.kubernetes?.container?.name).to.eql('opbeans-java');
});
});
describe('fetch service overview metadata details', () => {
it('handles service overview metadata with multiple kubernetes instances', async () => {
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/services/{serviceName}/metadata/details',
params: {
path: {
serviceName: 'opbeans-java',
},
query: {
start,
end,
},
},
});
const body: ServiceDetails = response.body;
const status: number = response.status;
expect(status).to.be(200);
expect(body.kubernetes?.deployments).to.eql(['opbeans-java', 'opbeans-java-2']);
expect(body.kubernetes?.namespaces).to.eql(['default']);
expect(body.kubernetes?.containerImages).to.eql([
'docker.elastic.co/observability-ci/opbeans-java@sha256:dda30dbabe5c43b8bcd62b48a727f04e9d17147443ea3b3ac2edfc44cb0e69fe',
'mysql@sha256:c8f03238ca1783d25af320877f063a36dbfce0daa56a7b4955e6c6e05ab5c70b',
]);
expect(body.kubernetes?.replicasets).to.eql([
'opbeans-java-5b5f75d696',
'opbeans-java-5b5f75d697',
]);
});
it('handles partial infra metrics data', async () => {
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/services/{serviceName}/metadata/details',
params: {
path: {
serviceName: 'opbeans-node',
},
query: {
start,
end,
},
},
});
const body: ServiceDetails = response.body;
const status: number = response.status;
expect(status).to.be(200);
expect(body.kubernetes?.containerImages).to.eql([
'docker.elastic.co/observability-ci/opbeans-node@sha256:f72b0bfdd0ca24e4f9d10ee73cf713a591dbfa40f1fe9404b04e6f2f3e166949',
'k8s.gcr.io/pause:3.1',
]);
expect(body.kubernetes?.deployments).to.eql([]);
expect(body.kubernetes?.namespaces).to.eql([]);
expect(body.kubernetes?.replicasets).to.eql([]);
});
it('handles empty infra metrics data', async () => {
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/services/{serviceName}/metadata/details',
params: {
path: {
serviceName: 'opbeans-ruby',
},
query: {
start,
end,
},
},
});
const body: ServiceDetails = response.body;
const status: number = response.status;
expect(status).to.be(200);
expect(body.kubernetes?.containerImages).to.eql([]);
expect(body.kubernetes?.namespaces).to.eql([]);
expect(body.kubernetes?.namespaces).to.eql([]);
expect(body.kubernetes?.replicasets).to.eql([]);
});
});
}
);
}