[APM] Agent explorer (PoC) (#143844)

Closes [142218](https://github.com/elastic/kibana/issues/142218)

- Introducing the Agent explorer view



https://user-images.githubusercontent.com/1313018/198403801-bd9aab9c-1f7e-4775-b3ed-e0e488eef513.mov

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yngrid Coello 2022-11-14 15:40:49 +01:00 committed by GitHub
parent fa69b424bc
commit 9c27f3d798
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1890 additions and 69 deletions

View file

@ -81,6 +81,7 @@ export type ApmFields = Fields &
'service.name': string;
'service.version': string;
'service.environment': string;
'service.language.name': string;
'service.node.name': string;
'service.runtime.name': string;
'service.runtime.version': string;

View file

@ -6,14 +6,14 @@
* Side Public License, v 1.
*/
import { random } from 'lodash';
import { flatten, random } from 'lodash';
import { apm, timerange } from '../..';
import { Instance } from '../lib/apm/instance';
import { Scenario } from '../cli/scenario';
import { getLogger } from '../cli/utils/get_common_services';
import { RunOptions } from '../cli/utils/parse_run_cli_flags';
import { ApmFields } from '../lib/apm/apm_fields';
import { Instance } from '../lib/apm/instance';
import { getSynthtraceEnvironment } from '../lib/utils/get_synthtrace_environment';
const ENVIRONMENT = getSynthtraceEnvironment(__filename);
@ -24,6 +24,12 @@ const scenario: Scenario<ApmFields> = async (runOptions: RunOptions) => {
const numServices = 500;
const languages = ['go', 'dotnet', 'java', 'python'];
const services = ['web', 'order-processing', 'api-backend', 'proxy'];
const agentVersions: Record<string, string[]> = {
go: ['2.1.0', '2.0.0', '1.15.0', '1.14.0', '1.13.1'],
dotnet: ['1.18.0', '1.17.0', '1.16.1', '1.16.0', '1.15.0'],
java: ['1.34.1', '1.34.0', '1.33.0', '1.32.0', '1.32.0'],
python: ['6.12.0', '6.11.0', '6.10.2', '6.10.1', '6.10.0'],
};
return {
generate: ({ from, to }) => {
@ -31,16 +37,27 @@ const scenario: Scenario<ApmFields> = async (runOptions: RunOptions) => {
const successfulTimestamps = range.ratePerMinute(180);
const instances = [...Array(numServices).keys()].map((index) =>
apm
.service({
name: `${services[index % services.length]}-${
languages[index % languages.length]
}-${index}`,
environment: ENVIRONMENT,
agentName: languages[index % languages.length],
})
.instance(`instance-${index}`)
const instances = flatten(
[...Array(numServices).keys()].map((index) => {
const language = languages[index % languages.length];
const agentLanguageVersions = agentVersions[language];
const numOfInstances = (index % 3) + 1;
return [...Array(numOfInstances).keys()].map((instanceIndex) =>
apm
.service({
name: `${services[index % services.length]}-${language}-${index}`,
environment: ENVIRONMENT,
agentName: language,
})
.instance(`instance-${index}-${instanceIndex}`)
.defaults({
'agent.version': agentLanguageVersions[index % agentLanguageVersions.length],
'service.language.name': language,
})
);
})
);
const urls = ['GET /order/{id}', 'POST /basket/{id}', 'DELETE /basket', 'GET /products'];

View file

@ -442,6 +442,10 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
'observability:apmAgentExplorerView': {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
'observability:apmAWSLambdaPriceFactor': {
type: 'text',
_meta: { description: 'Non-default value of setting.' },

View file

@ -46,6 +46,7 @@ export interface UsageStats {
'observability:apmAWSLambdaPriceFactor': string;
'observability:apmAWSLambdaRequestCostPerMillion': number;
'observability:enableInfrastructureHostsView': boolean;
'observability:apmAgentExplorerView': boolean;
'visualize:enableLabs': boolean;
'visualization:heatmap:maxBuckets': number;
'visualization:colorMapping': string;

View file

@ -8916,6 +8916,12 @@
"description": "Non-default value of setting."
}
},
"observability:apmAgentExplorerView": {
"type": "boolean",
"_meta": {
"description": "Non-default value of setting."
}
},
"observability:apmAWSLambdaPriceFactor": {
"type": "text",
"_meta": {

View file

@ -0,0 +1,16 @@
/*
* 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.
*/
export enum AgentExplorerFieldName {
ServiceName = 'serviceName',
Environments = 'environments',
AgentName = 'agentName',
AgentVersion = 'agentVersion',
AgentLastVersion = 'agentLastVersion',
AgentDocsPageUrl = 'agentDocsPageUrl',
Instances = 'instances',
}

View file

@ -45,6 +45,9 @@ export const AGENT_NAMES: AgentName[] = [
...OPEN_TELEMETRY_AGENT_NAMES,
];
export const isOpenTelemetryAgentName = (agentName: AgentName) =>
OPEN_TELEMETRY_AGENT_NAMES.includes(agentName);
export const JAVA_AGENT_NAMES: AgentName[] = ['java', 'opentelemetry/java'];
export function isJavaAgentName(

View file

@ -0,0 +1,60 @@
/*
* 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 { EuiIcon, EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { isOpenTelemetryAgentName } from '../../../../../../common/agent_name';
import { NOT_AVAILABLE_LABEL } from '../../../../../../common/i18n';
import { AgentName } from '../../../../../../typings/es_schemas/ui/fields/agent';
interface AgentExplorerDocsLinkProps {
agentName: AgentName;
repositoryUrl?: string;
}
export function AgentExplorerDocsLink({
agentName,
repositoryUrl,
}: AgentExplorerDocsLinkProps) {
if (!repositoryUrl) {
return <>{NOT_AVAILABLE_LABEL}</>;
}
return (
<EuiLink
data-test-subj={`agentExplorerDocsLink_${agentName}`}
href={repositoryUrl}
target="_blank"
external
>
{isOpenTelemetryAgentName(agentName) ? (
<EuiIcon
type="documentation"
size="m"
title={i18n.translate('xpack.apm.agentExplorer.docsLink.otel.logo', {
defaultMessage: 'Opentelemetry logo',
})}
/>
) : (
<EuiIcon
type="logoElastic"
size="m"
title={i18n.translate(
'xpack.apm.agentExplorer.docsLink.elastic.logo',
{
defaultMessage: 'Elastic logo',
}
)}
/>
)}{' '}
{i18n.translate('xpack.apm.agentExplorer.docsLink.message', {
defaultMessage: 'Docs',
})}
</EuiLink>
);
}

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TypeOf } from '@kbn/typed-react-router-config';
import React from 'react';
import { AgentExplorerFieldName } from '../../../../../../../common/agent_explorer';
import { AgentName } from '../../../../../../../typings/es_schemas/ui/fields/agent';
import { useApmPluginContext } from '../../../../../../context/apm_plugin/use_apm_plugin_context';
import { useDefaultTimeRange } from '../../../../../../hooks/use_default_time_range';
import { ApmRoutes } from '../../../../../routing/apm_route_config';
import { ServiceLink } from '../../../../../shared/service_link';
import { StickyProperties } from '../../../../../shared/sticky_properties';
import { getComparisonEnabled } from '../../../../../shared/time_comparison/get_comparison_enabled';
import { TruncateWithTooltip } from '../../../../../shared/truncate_with_tooltip';
import { AgentExplorerDocsLink } from '../../agent_explorer_docs_link';
export function AgentContextualInformation({
agentName,
serviceName,
agentDocsPageUrl,
instances,
query,
}: {
agentName: AgentName;
serviceName: string;
agentDocsPageUrl?: string;
instances: number;
query: TypeOf<ApmRoutes, '/settings/agent-explorer'>['query'];
}) {
const { core } = useApmPluginContext();
const comparisonEnabled = getComparisonEnabled({ core });
const { rangeFrom, rangeTo } = useDefaultTimeRange();
const stickyProperties = [
{
label: i18n.translate('xpack.apm.agentInstancesDetails.serviceLabel', {
defaultMessage: 'Service',
}),
fieldName: AgentExplorerFieldName.ServiceName,
val: (
<TruncateWithTooltip
data-test-subj="apmAgentExplorerListServiceLink"
text={serviceName}
content={
<ServiceLink
agentName={agentName}
query={{
kuery: query.kuery,
serviceGroup: '',
rangeFrom,
rangeTo,
environment: query.environment,
comparisonEnabled,
}}
serviceName={serviceName}
/>
}
/>
),
width: '25%',
},
{
label: i18n.translate('xpack.apm.agentInstancesDetails.agentNameLabel', {
defaultMessage: 'Agent Name',
}),
fieldName: AgentExplorerFieldName.AgentName,
val: (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem className="eui-textTruncate">
<span className="eui-textTruncate">{agentName}</span>
</EuiFlexItem>
</EuiFlexGroup>
),
width: '25%',
},
{
label: i18n.translate('xpack.apm.agentInstancesDetails.intancesLabel', {
defaultMessage: 'Instances',
}),
fieldName: 'instances',
val: (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem className="eui-textTruncate">
<span className="eui-textTruncate">{instances}</span>
</EuiFlexItem>
</EuiFlexGroup>
),
width: '25%',
},
{
label: i18n.translate(
'xpack.apm.agentInstancesDetails.agentDocsUrlLabel',
{
defaultMessage: 'Agent documentation',
}
),
fieldName: AgentExplorerFieldName.AgentDocsPageUrl,
val: (
<TruncateWithTooltip
data-test-subj="apmAgentExplorerListDocsLink"
text={`${agentName} agent docs`}
content={
<AgentExplorerDocsLink
agentName={agentName}
repositoryUrl={agentDocsPageUrl}
/>
}
/>
),
width: '25%',
},
];
return <StickyProperties stickyProperties={stickyProperties} />;
}

View file

@ -0,0 +1,217 @@
/*
* 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 { EuiLink, EuiLoadingContent, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { ValuesType } from 'utility-types';
import { AgentExplorerFieldName } from '../../../../../../../common/agent_explorer';
import { isOpenTelemetryAgentName } from '../../../../../../../common/agent_name';
import {
getServiceNodeName,
SERVICE_NODE_NAME_MISSING,
} from '../../../../../../../common/service_nodes';
import { AgentName } from '../../../../../../../typings/es_schemas/ui/fields/agent';
import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api';
import { unit } from '../../../../../../utils/style';
import { EnvironmentBadge } from '../../../../../shared/environment_badge';
import { ItemsBadge } from '../../../../../shared/item_badge';
import { ServiceNodeMetricOverviewLink } from '../../../../../shared/links/apm/service_node_metric_overview_link';
import {
ITableColumn,
ManagedTable,
} from '../../../../../shared/managed_table';
import { PopoverTooltip } from '../../../../../shared/popover_tooltip';
import { TimestampTooltip } from '../../../../../shared/timestamp_tooltip';
import { TruncateWithTooltip } from '../../../../../shared/truncate_with_tooltip';
type AgentExplorerInstance = ValuesType<
APIReturnType<'GET /internal/apm/services/{serviceName}/agent_instances'>['items']
>;
enum AgentExplorerInstanceFieldName {
InstanceName = 'serviceNode',
Environments = 'environments',
AgentVersion = 'agentVersion',
LastReport = 'lastReport',
}
export function getInstanceColumns(
serviceName: string,
agentName: AgentName,
agentDocsPageUrl?: string
): Array<ITableColumn<AgentExplorerInstance>> {
return [
{
field: AgentExplorerInstanceFieldName.InstanceName,
name: i18n.translate(
'xpack.apm.agentExplorerInstanceTable.InstanceColumnLabel',
{
defaultMessage: 'Instance',
}
),
sortable: true,
render: (_, { serviceNode }) => {
const displayedName = getServiceNodeName(serviceNode);
return serviceNode === SERVICE_NODE_NAME_MISSING ? (
<>
{displayedName}
<PopoverTooltip
ariaLabel={i18n.translate(
'xpack.apm.agentExplorerInstanceTable.noServiceNodeName.tooltip',
{
defaultMessage: 'Tooltip for missing serviceNodeName',
}
)}
>
<EuiText style={{ width: `${unit * 24}px` }} size="s">
<p>
<FormattedMessage
defaultMessage="You can configure the service node name through {seeDocs}."
id="xpack.apm.agentExplorerInstanceTable.noServiceNodeName.tooltip.linkToDocs"
values={{
seeDocs: (
<EuiLink
href={`${agentDocsPageUrl}${
!isOpenTelemetryAgentName(agentName)
? 'configuration.html#service-node-name'
: ''
}`}
target="_blank"
>
{i18n.translate(
'xpack.apm.agentExplorerInstanceTable.noServiceNodeName.configurationOptions',
{
defaultMessage: 'configuration options',
}
)}
</EuiLink>
),
}}
/>
</p>
</EuiText>
</PopoverTooltip>
</>
) : (
<TruncateWithTooltip
data-test-subj="apmAgentExplorerInstanceListServiceLink"
text={displayedName}
content={
<>
{serviceNode ? (
<ServiceNodeMetricOverviewLink
serviceName={serviceName}
serviceNodeName={serviceNode}
>
<span className="eui-textTruncate">{displayedName}</span>
</ServiceNodeMetricOverviewLink>
) : (
<span className="eui-textTruncate">{displayedName}</span>
)}
</>
}
/>
);
},
},
{
field: AgentExplorerInstanceFieldName.Environments,
name: i18n.translate(
'xpack.apm.agentExplorerInstanceTable.environmentColumnLabel',
{
defaultMessage: 'Environment',
}
),
width: `${unit * 16}px`,
sortable: true,
render: (_, { environments }) => (
<EnvironmentBadge environments={environments} />
),
},
{
field: AgentExplorerInstanceFieldName.AgentVersion,
name: i18n.translate(
'xpack.apm.agentExplorerInstanceTable.agentVersionColumnLabel',
{ defaultMessage: 'Agent Version' }
),
width: `${unit * 16}px`,
sortable: true,
render: (_, { agentVersion }) => {
const versions = [agentVersion];
return (
<ItemsBadge
items={versions}
multipleItemsMessage={i18n.translate(
'xpack.apm.agentExplorerInstanceTable.agentVersionColumnLabel.multipleVersions',
{
values: { versionsCount: versions.length },
defaultMessage:
'{versionsCount, plural, one {1 version} other {# versions}}',
}
)}
/>
);
},
},
{
field: AgentExplorerInstanceFieldName.LastReport,
name: i18n.translate(
'xpack.apm.agentExplorerInstanceTable.lastReportColumnLabel',
{
defaultMessage: 'Last report',
}
),
width: `${unit * 16}px`,
sortable: true,
render: (_, { lastReport }) => <TimestampTooltip time={lastReport} />,
},
];
}
interface Props {
serviceName: string;
agentName: AgentName;
agentDocsPageUrl?: string;
items: AgentExplorerInstance[];
isLoading: boolean;
}
export function AgentInstancesDetails({
serviceName,
agentName,
agentDocsPageUrl,
items,
isLoading,
}: Props) {
if (isLoading) {
return (
<div style={{ width: '50%' }}>
<EuiLoadingContent data-test-subj="loadingSpinner" />
</div>
);
}
return (
<ManagedTable
columns={getInstanceColumns(serviceName, agentName, agentDocsPageUrl)}
items={items}
noItemsMessage={i18n.translate(
'xpack.apm.agentExplorer.table.noResults',
{
defaultMessage: 'No data found',
}
)}
initialSortField={AgentExplorerFieldName.AgentVersion}
initialSortDirection="desc"
isLoading={isLoading}
initialPageSize={25}
/>
);
}

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiHorizontalRule,
EuiPortal,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useApmParams } from '../../../../../hooks/use_apm_params';
import { FETCH_STATUS } from '../../../../../hooks/use_fetcher';
import { useProgressiveFetcher } from '../../../../../hooks/use_progressive_fetcher';
import { useTimeRange } from '../../../../../hooks/use_time_range';
import { ResponsiveFlyout } from '../../../transaction_details/waterfall_with_summary/waterfall_container/waterfall/responsive_flyout';
import { AgentExplorerItem } from '../agent_list';
import { AgentContextualInformation } from './agent_contextual_information';
import { AgentInstancesDetails } from './agent_instances_details';
function useAgentInstancesFetcher({ serviceName }: { serviceName: string }) {
const {
query: { environment, kuery },
} = useApmParams('/settings/agent-explorer');
const { start, end } = useTimeRange({ rangeFrom: 'now-24h', rangeTo: 'now' });
return useProgressiveFetcher(
(callApmApi) => {
return callApmApi(
'GET /internal/apm/services/{serviceName}/agent_instances',
{
params: {
path: {
serviceName,
},
query: {
environment,
start,
end,
kuery,
},
},
}
);
},
[start, end, serviceName, environment, kuery]
);
}
interface Props {
agent: AgentExplorerItem;
onClose: () => void;
}
export function AgentInstances({ agent, onClose }: Props) {
const { query } = useApmParams('/settings/agent-explorer');
const instances = useAgentInstancesFetcher({
serviceName: agent.serviceName,
});
const isLoading = instances.status === FETCH_STATUS.LOADING;
return (
<EuiPortal>
<ResponsiveFlyout onClose={onClose} ownFocus={true} maxWidth={false}>
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiTitle>
<h4>
{i18n.translate(
'xpack.apm.agentExplorer.instancesFlyout.title',
{
defaultMessage: 'Agent Instances',
}
)}
</h4>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<AgentContextualInformation
agentName={agent.agentName}
serviceName={agent.serviceName}
agentDocsPageUrl={agent.agentDocsPageUrl}
instances={agent.instances}
query={query}
/>
<EuiHorizontalRule margin="m" />
<EuiSpacer size="m" />
<AgentInstancesDetails
serviceName={agent.serviceName}
agentName={agent.agentName}
agentDocsPageUrl={agent.agentDocsPageUrl}
isLoading={isLoading}
items={instances.data?.items ?? []}
/>
</EuiFlyoutBody>
</ResponsiveFlyout>
</EuiPortal>
);
}

View file

@ -0,0 +1,210 @@
/*
* 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 {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo, useState } from 'react';
import { ValuesType } from 'utility-types';
import { AgentExplorerFieldName } from '../../../../../../common/agent_explorer';
import { AgentName } from '../../../../../../typings/es_schemas/ui/fields/agent';
import { APIReturnType } from '../../../../../services/rest/create_call_apm_api';
import { unit } from '../../../../../utils/style';
import { AgentIcon } from '../../../../shared/agent_icon';
import { EnvironmentBadge } from '../../../../shared/environment_badge';
import { ItemsBadge } from '../../../../shared/item_badge';
import { ITableColumn, ManagedTable } from '../../../../shared/managed_table';
import { TruncateWithTooltip } from '../../../../shared/truncate_with_tooltip';
import { AgentExplorerDocsLink } from '../agent_explorer_docs_link';
import { AgentInstances } from '../agent_instances';
export type AgentExplorerItem = ValuesType<
APIReturnType<'GET /internal/apm/get_agents_per_service'>['items']
>;
export function getAgentsColumns({
selectedAgent,
onAgentSelected,
}: {
selectedAgent?: AgentExplorerItem;
onAgentSelected: (agent: AgentExplorerItem) => void;
}): Array<ITableColumn<AgentExplorerItem>> {
return [
{
field: AgentExplorerFieldName.ServiceName,
name: '',
width: `${unit * 3}px`,
render: (_, agent) => {
const isSelected = selectedAgent === agent;
return (
<EuiToolTip
content={i18n.translate(
'xpack.apm.agentExplorerTable.viewAgentInstances',
{
defaultMessage: 'Toggle agent instances view',
}
)}
delay="long"
>
<EuiButtonIcon
size="xs"
iconSize="s"
aria-label="Toggle agent instances view"
data-test-subj="apmAgentExplorerListToggle"
onClick={() => onAgentSelected(agent)}
display={isSelected ? 'base' : 'empty'}
iconType={isSelected ? 'minimize' : 'expand'}
isSelected={isSelected}
/>
</EuiToolTip>
);
},
},
{
field: AgentExplorerFieldName.ServiceName,
name: i18n.translate(
'xpack.apm.agentExplorerTable.serviceNameColumnLabel',
{
defaultMessage: 'Service Name',
}
),
sortable: true,
render: (_, { serviceName, agentName }) => (
<TruncateWithTooltip
data-test-subj="apmAgentExplorerListServiceLink"
text={serviceName}
content={
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<AgentIcon agentName={agentName} />
</EuiFlexItem>
<EuiFlexItem className="eui-textTruncate">
<span className="eui-textTruncate">{serviceName}</span>
</EuiFlexItem>
</EuiFlexGroup>
}
/>
),
},
{
field: AgentExplorerFieldName.Environments,
name: i18n.translate(
'xpack.apm.agentExplorerTable.environmentColumnLabel',
{
defaultMessage: 'Environment',
}
),
width: `${unit * 16}px`,
sortable: true,
render: (_, { environments }) => (
<EnvironmentBadge environments={environments} />
),
},
{
field: AgentExplorerFieldName.Instances,
name: i18n.translate(
'xpack.apm.agentExplorerTable.instancesColumnLabel',
{
defaultMessage: 'Instances',
}
),
width: `${unit * 8}px`,
sortable: true,
},
{
field: AgentExplorerFieldName.AgentName,
width: `${unit * 12}px`,
name: i18n.translate(
'xpack.apm.agentExplorerTable.agentNameColumnLabel',
{ defaultMessage: 'Agent Name' }
),
sortable: true,
},
{
field: AgentExplorerFieldName.AgentVersion,
name: i18n.translate(
'xpack.apm.agentExplorerTable.agentVersionColumnLabel',
{ defaultMessage: 'Agent Version' }
),
width: `${unit * 8}px`,
render: (_, { agentVersion }) => (
<ItemsBadge
items={agentVersion}
multipleItemsMessage={i18n.translate(
'xpack.apm.agentExplorerTable.agentVersionColumnLabel.multipleVersions',
{
values: { versionsCount: agentVersion.length },
defaultMessage:
'{versionsCount, plural, one {1 version} other {# versions}}',
}
)}
/>
),
},
{
field: AgentExplorerFieldName.AgentDocsPageUrl,
name: i18n.translate(
'xpack.apm.agentExplorerTable.agentDocsColumnLabel',
{ defaultMessage: 'Agent Docs' }
),
width: `${unit * 8}px`,
render: (_, { agentName, agentDocsPageUrl }) => (
<EuiToolTip content={`${agentName} agent docs`}>
<AgentExplorerDocsLink
agentName={agentName as AgentName}
repositoryUrl={agentDocsPageUrl}
/>
</EuiToolTip>
),
},
];
}
interface Props {
items: AgentExplorerItem[];
noItemsMessage: React.ReactNode;
isLoading: boolean;
}
export function AgentList({ items, noItemsMessage, isLoading }: Props) {
const [selectedAgent, setSelectedAgent] = useState<AgentExplorerItem>();
const onAgentSelected = (agent: AgentExplorerItem) => {
setSelectedAgent(agent);
};
const onCloseFlyout = () => {
setSelectedAgent(undefined);
};
const agentColumns = useMemo(
() => getAgentsColumns({ selectedAgent, onAgentSelected }),
[selectedAgent]
);
return (
<>
{selectedAgent && (
<AgentInstances agent={selectedAgent} onClose={onCloseFlyout} />
)}
<ManagedTable
columns={agentColumns}
items={items}
noItemsMessage={noItemsMessage}
initialSortField={AgentExplorerFieldName.Instances}
initialSortDirection="desc"
isLoading={isLoading}
initialPageSize={25}
/>
</>
);
}

View file

@ -0,0 +1,196 @@
/*
* 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 {
EuiCallOut,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useHistory } from 'react-router-dom';
import {
SERVICE_LANGUAGE_NAME,
SERVICE_NAME,
} from '../../../../../common/elasticsearch_fieldnames';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { FETCH_STATUS } from '../../../../hooks/use_fetcher';
import { useProgressiveFetcher } from '../../../../hooks/use_progressive_fetcher';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { KueryBar } from '../../../shared/kuery_bar';
import * as urlHelpers from '../../../shared/links/url_helpers';
import { SuggestionsSelect } from '../../../shared/suggestions_select';
import { TechnicalPreviewBadge } from '../../../shared/technical_preview_badge';
import { AgentList } from './agent_list';
function useAgentExplorerFetcher({
start,
end,
}: {
start: string;
end: string;
}) {
const {
query: { environment, serviceName, agentLanguage, kuery },
} = useApmParams('/settings/agent-explorer');
return useProgressiveFetcher(
(callApmApi) => {
return callApmApi('GET /internal/apm/get_agents_per_service', {
params: {
query: {
environment,
serviceName,
agentLanguage,
kuery,
start,
end,
},
},
});
},
[environment, serviceName, agentLanguage, kuery, start, end]
);
}
export function AgentExplorer() {
const history = useHistory();
const {
query: { serviceName, agentLanguage },
} = useApmParams('/settings/agent-explorer');
const { start, end } = useTimeRange({ rangeFrom: 'now-24h', rangeTo: 'now' });
const agents = useAgentExplorerFetcher({ start, end });
const isLoading = agents.status === FETCH_STATUS.LOADING;
const noItemsMessage = (
<EuiEmptyPrompt
title={
<div>
{i18n.translate('xpack.apm.agentExplorer.notFoundLabel', {
defaultMessage: 'No Agents found',
})}
</div>
}
titleSize="s"
/>
);
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText color="subdued">
{i18n.translate('xpack.apm.settings.agentExplorer.descriptionText', {
defaultMessage:
'Agent Explorer Technical Preview provides an inventory and details of deployed Agents.',
})}
</EuiText>
</EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexItem grow={false}>
<EuiTitle>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<h2>
{i18n.translate('xpack.apm.settings.agentExplorer.title', {
defaultMessage: 'Agent explorer',
})}
</h2>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TechnicalPreviewBadge icon="beaker" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiTitle>
</EuiFlexItem>
<EuiSpacer />
<EuiFlexItem grow={false}>
<KueryBar />
</EuiFlexItem>
<EuiSpacer />
<EuiFlexItem>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<SuggestionsSelect
prepend={i18n.translate(
'xpack.apm.agentExplorer.serviceNameSelect.label',
{
defaultMessage: 'Service name',
}
)}
defaultValue={serviceName}
fieldName={SERVICE_NAME}
onChange={(value) => {
urlHelpers.push(history, {
query: { serviceName: value ?? '' },
});
}}
placeholder={i18n.translate(
'xpack.apm.agentExplorer.serviceNameSelect.placeholder',
{
defaultMessage: 'All',
}
)}
start={start}
end={end}
dataTestSubj="agentExplorerServiceNameSelect"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SuggestionsSelect
prepend={i18n.translate(
'xpack.apm.agentExplorer.agentLanguageSelect.label',
{
defaultMessage: 'Agent language',
}
)}
defaultValue={agentLanguage}
fieldName={SERVICE_LANGUAGE_NAME}
onChange={(value) => {
urlHelpers.push(history, {
query: { agentLanguage: value ?? '' },
});
}}
placeholder={i18n.translate(
'xpack.apm.agentExplorer.agentLanguageSelect.placeholder',
{
defaultMessage: 'All',
}
)}
start={start}
end={end}
dataTestSubj="agentExplorerAgentLanguageSelect"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiCallOut
size="s"
title={i18n.translate('xpack.apm.agentExplorer.callout.24hoursData', {
defaultMessage: 'Information based on the lastest 24h',
})}
iconType="clock"
/>
</EuiFlexItem>
<EuiSpacer />
<EuiFlexItem>
<AgentList
isLoading={isLoading}
items={agents.data?.items ?? []}
noItemsMessage={noItemsMessage}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -4,23 +4,25 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import * as t from 'io-ts';
import { Outlet } from '@kbn/typed-react-router-config';
import { i18n } from '@kbn/i18n';
import { Outlet } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React from 'react';
import { Redirect } from 'react-router-dom';
import { agentConfigurationPageStepRt } from '../../../../common/agent_configuration/constants';
import { environmentRt } from '../../../../common/environment_rt';
import { Breadcrumb } from '../../app/breadcrumb';
import { SettingsTemplate } from '../templates/settings_template';
import { AgentConfigurations } from '../../app/settings/agent_configurations';
import { CreateAgentConfigurationRouteView } from './create_agent_configuration_route_view';
import { EditAgentConfigurationRouteView } from './edit_agent_configuration_route_view';
import { AgentExplorer } from '../../app/settings/agent_explorer';
import { AgentKeys } from '../../app/settings/agent_keys';
import { AnomalyDetection } from '../../app/settings/anomaly_detection';
import { ApmIndices } from '../../app/settings/apm_indices';
import { CustomLinkOverview } from '../../app/settings/custom_link';
import { Schema } from '../../app/settings/schema';
import { AnomalyDetection } from '../../app/settings/anomaly_detection';
import { AgentKeys } from '../../app/settings/agent_keys';
import { GeneralSettings } from '../../app/settings/general_settings';
import { Schema } from '../../app/settings/schema';
import { SettingsTemplate } from '../templates/settings_template';
import { CreateAgentConfigurationRouteView } from './create_agent_configuration_route_view';
import { EditAgentConfigurationRouteView } from './edit_agent_configuration_route_view';
function page({
title,
@ -141,6 +143,28 @@ export const settings = {
element: <AgentKeys />,
tab: 'agent-keys',
}),
'/settings/agent-explorer': {
...page({
title: i18n.translate(
'xpack.apm.views.settings.agentExplorer.title',
{
defaultMessage: 'Agent explorer',
}
),
element: <AgentExplorer />,
tab: 'agent-explorer',
}),
params: t.type({
query: t.intersection([
environmentRt,
t.type({
kuery: t.string,
agentLanguage: t.string,
serviceName: t.string,
}),
]),
}),
},
'/settings': {
element: <Redirect to="/settings/general-settings" />,
},

View file

@ -6,13 +6,17 @@
*/
import { EuiPageHeaderProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { CoreStart } from '@kbn/core/public';
import { ApmMainTemplate } from './apm_main_template';
import { i18n } from '@kbn/i18n';
import { enableAgentExplorerView } from '@kbn/observability-plugin/public';
import React from 'react';
import { useDefaultEnvironment } from '../../../hooks/use_default_environment';
import { Environment } from '../../../../common/environment_rt';
import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge';
import { ApmRouter } from '../apm_route_config';
import { ApmMainTemplate } from './apm_main_template';
type Tab = NonNullable<EuiPageHeaderProps['tabs']>[0] & {
key:
@ -22,7 +26,8 @@ type Tab = NonNullable<EuiPageHeaderProps['tabs']>[0] & {
| 'apm-indices'
| 'custom-links'
| 'schema'
| 'general-settings';
| 'general-settings'
| 'agent-explorer';
hidden?: boolean;
};
@ -34,7 +39,9 @@ interface Props {
export function SettingsTemplate({ children, selectedTab }: Props) {
const { core } = useApmPluginContext();
const router = useApmRouter();
const tabs = getTabs({ core, selectedTab, router });
const defaultEnvironment = useDefaultEnvironment();
const tabs = getTabs({ core, selectedTab, router, defaultEnvironment });
return (
<ApmMainTemplate
@ -55,13 +62,20 @@ function getTabs({
core,
selectedTab,
router,
defaultEnvironment,
}: {
core: CoreStart;
selectedTab: Tab['key'];
router: ApmRouter;
defaultEnvironment: Environment;
}) {
const canReadMlJobs = !!core.application.capabilities.ml?.canGetJobs;
const agentExplorerEnabled = core.uiSettings.get<boolean>(
enableAgentExplorerView,
false
);
const tabs: Tab[] = [
{
key: 'general-settings',
@ -77,6 +91,22 @@ function getTabs({
}),
href: router.link('/settings/agent-configuration'),
},
{
key: 'agent-explorer',
label: i18n.translate('xpack.apm.settings.agentExplorer', {
defaultMessage: 'Agent Explorer',
}),
href: router.link('/settings/agent-explorer', {
query: {
environment: defaultEnvironment,
kuery: '',
agentLanguage: '',
serviceName: '',
},
}),
append: <TechnicalPreviewBadge icon="beaker" />,
hidden: !agentExplorerEnabled,
},
{
key: 'agent-keys',
label: i18n.translate('xpack.apm.settings.agentKeys', {
@ -117,9 +147,10 @@ function getTabs({
return tabs
.filter((t) => !t.hidden)
.map(({ href, key, label }) => ({
.map(({ href, key, label, append }) => ({
href,
label,
append,
isSelected: key === selectedTab,
}));
}

View file

@ -7,40 +7,23 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBadge, EuiToolTip } from '@elastic/eui';
import { ItemsBadge } from '../item_badge';
interface Props {
environments: string[];
}
export function EnvironmentBadge({ environments = [] }: Props) {
if (environments.length < 2) {
return (
<>
{environments.map((env) => (
<EuiBadge color="hollow" key={env}>
{env}
</EuiBadge>
))}
</>
);
}
return (
<EuiToolTip
position="right"
content={environments.map((env) => (
<React.Fragment key={env}>
{env}
<br />
</React.Fragment>
))}
>
<EuiBadge>
{i18n.translate('xpack.apm.servicesTable.environmentCount', {
<ItemsBadge
items={environments ?? []}
multipleItemsMessage={i18n.translate(
'xpack.apm.servicesTable.environmentCount',
{
values: { environmentCount: environments.length },
defaultMessage:
'{environmentCount, plural, one {1 environment} other {# environments}}',
})}
</EuiBadge>
</EuiToolTip>
}
)}
/>
);
}

View file

@ -0,0 +1,47 @@
/*
* 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 { EuiBadge, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
interface Props {
items: string[];
multipleItemsMessage?: string;
}
export function ItemsBadge({
items = [],
multipleItemsMessage = i18n.translate('xpack.apm.itemsBadge.placeholder', {
values: { itemsCount: items.length },
defaultMessage: '{itemsCount, plural, one {1 item} other {# items}}',
}),
}: Props) {
if (items.length < 2) {
return (
<>
{items.map((item) => (
<EuiBadge color="hollow" key={item}>
{item}
</EuiBadge>
))}
</>
);
}
return (
<EuiToolTip
position="right"
content={items.map((item) => (
<React.Fragment key={item}>
{item}
<br />
</React.Fragment>
))}
>
<EuiBadge>{multipleItemsMessage}</EuiBadge>
</EuiToolTip>
);
}

View file

@ -0,0 +1,41 @@
/*
* 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 { EuiButtonIcon, EuiPopover } from '@elastic/eui';
import React, { useState } from 'react';
interface PopoverTooltipProps {
ariaLabel?: string;
children: React.ReactNode;
}
export function PopoverTooltip({ ariaLabel, children }: PopoverTooltipProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
return (
<EuiPopover
anchorPosition={'upCenter'}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
button={
<EuiButtonIcon
aria-label={ariaLabel}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
setIsPopoverOpen(!isPopoverOpen);
event.stopPropagation();
}}
size="xs"
color="primary"
iconType="questionInCircle"
style={{ height: 'auto' }}
/>
}
>
{children}
</EuiPopover>
);
}

View file

@ -0,0 +1,21 @@
/*
* 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 { UI_SETTINGS } from '@kbn/data-plugin/public';
import { TimePickerTimeDefaults } from '../components/shared/date_picker/typings';
import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context';
export function useDefaultTimeRange() {
const { core } = useApmPluginContext();
const { from: rangeFrom, to: rangeTo } =
core.uiSettings.get<TimePickerTimeDefaults>(
UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS
);
return { rangeFrom, rangeTo };
}

View file

@ -0,0 +1,108 @@
/*
* 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 { ProcessorEvent } from '@kbn/observability-plugin/common';
import {
kqlQuery,
rangeQuery,
termQuery,
} from '@kbn/observability-plugin/server';
import { SERVICE_NODE_NAME_MISSING } from '../../../common/service_nodes';
import {
AGENT_NAME,
AGENT_VERSION,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
SERVICE_NODE_NAME,
} from '../../../common/elasticsearch_fieldnames';
import { environmentQuery } from '../../../common/utils/environment_query';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
const MAX_NUMBER_OF_SERVICE_NODES = 500;
export async function getAgentInstances({
environment,
serviceName,
kuery,
apmEventClient,
start,
end,
}: {
environment: string;
serviceName?: string;
kuery: string;
apmEventClient: APMEventClient;
start: number;
end: number;
}) {
const response = await apmEventClient.search('get_agent_instances', {
apm: {
events: [ProcessorEvent.metric],
},
body: {
track_total_hits: false,
size: 0,
query: {
bool: {
filter: [
{
exists: {
field: AGENT_NAME,
},
},
{
exists: {
field: AGENT_VERSION,
},
},
...rangeQuery(start, end),
...environmentQuery(environment),
...kqlQuery(kuery),
...(serviceName ? termQuery(SERVICE_NAME, serviceName) : []),
],
},
},
aggs: {
serviceNodes: {
terms: {
field: SERVICE_NODE_NAME,
missing: SERVICE_NODE_NAME_MISSING,
size: MAX_NUMBER_OF_SERVICE_NODES,
},
aggs: {
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
},
},
sample: {
top_metrics: {
metrics: [{ field: AGENT_VERSION } as const],
sort: {
'@timestamp': 'desc' as const,
},
},
},
},
},
},
},
});
return (
response.aggregations?.serviceNodes.buckets.map((agentInstance) => ({
serviceNode: agentInstance.key as string,
environments: agentInstance.environments.buckets.map(
(environmentBucket) => environmentBucket.key as string
),
agentVersion: agentInstance.sample.top[0].metrics[
AGENT_VERSION
] as string,
lastReport: agentInstance.sample.top[0].sort[0] as string,
})) ?? []
);
}

View file

@ -0,0 +1,47 @@
/*
* 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 { isOpenTelemetryAgentName } from '../../../common/agent_name';
import { AgentName } from '../../../typings/es_schemas/ui/fields/agent';
const agentsDocPageName: Partial<Record<AgentName, string>> = {
go: 'go',
java: 'java',
'js-base': 'rum-js',
'iOS/swift': 'swift',
'rum-js': 'rum-js',
nodejs: 'nodejs',
python: 'python',
dotnet: 'dotnet',
ruby: 'ruby',
php: 'php',
'opentelemetry/cpp': 'cpp',
'opentelemetry/dotnet': 'net',
'opentelemetry/erlang': 'erlang',
'opentelemetry/go': 'go',
'opentelemetry/java': 'java',
'opentelemetry/nodejs': 'js',
'opentelemetry/php': 'php',
'opentelemetry/python': 'python',
'opentelemetry/ruby': 'ruby',
'opentelemetry/swift': 'swift',
'opentelemetry/webjs': 'js',
};
export const getAgentDocsPageUrl = (agentName: AgentName) => {
const agentDocsPageName = agentsDocPageName[agentName];
if (!agentDocsPageName) {
return undefined;
}
if (isOpenTelemetryAgentName(agentName)) {
return `https://opentelemetry.io/docs/instrumentation/${agentDocsPageName}`;
}
return `https://www.elastic.co/guide/en/apm/agent/${agentDocsPageName}/current/`;
};

View file

@ -0,0 +1,50 @@
/*
* 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 { AgentName } from '../../../typings/es_schemas/ui/fields/agent';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { RandomSampler } from '../../lib/helpers/get_random_sampler';
import { getAgentsItems } from './get_agents_items';
import { getAgentDocsPageUrl } from './get_agent_url_repository';
export async function getAgents({
environment,
serviceName,
agentLanguage,
kuery,
apmEventClient,
start,
end,
randomSampler,
}: {
environment: string;
serviceName?: string;
agentLanguage?: string;
kuery: string;
apmEventClient: APMEventClient;
start: number;
end: number;
randomSampler: RandomSampler;
}) {
const items = await getAgentsItems({
environment,
serviceName,
agentLanguage,
kuery,
apmEventClient,
start,
end,
randomSampler,
});
return {
items: items.map((item) => ({
...item,
agentDocsPageUrl: getAgentDocsPageUrl(item.agentName as AgentName),
})),
};
}

View file

@ -0,0 +1,136 @@
/*
* 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 { ProcessorEvent } from '@kbn/observability-plugin/common/processor_event';
import {
kqlQuery,
rangeQuery,
termQuery,
} from '@kbn/observability-plugin/server/utils/queries';
import {
AGENT_NAME,
AGENT_VERSION,
SERVICE_ENVIRONMENT,
SERVICE_LANGUAGE_NAME,
SERVICE_NAME,
SERVICE_NODE_NAME,
} from '../../../common/elasticsearch_fieldnames';
import { environmentQuery } from '../../../common/utils/environment_query';
import { AgentName } from '../../../typings/es_schemas/ui/fields/agent';
import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { RandomSampler } from '../../lib/helpers/get_random_sampler';
import { MAX_NUMBER_OF_SERVICES } from '../services/get_services/get_services_items';
interface AggregationParams {
environment: string;
serviceName?: string;
agentLanguage?: string;
kuery: string;
apmEventClient: APMEventClient;
start: number;
end: number;
randomSampler: RandomSampler;
}
export async function getAgentsItems({
environment,
agentLanguage,
serviceName,
kuery,
apmEventClient,
start,
end,
randomSampler,
}: AggregationParams) {
const response = await apmEventClient.search('get_agent_details', {
apm: {
events: [ProcessorEvent.metric],
},
body: {
track_total_hits: false,
size: 0,
query: {
bool: {
filter: [
{
exists: {
field: AGENT_NAME,
},
},
{
exists: {
field: AGENT_VERSION,
},
},
...rangeQuery(start, end),
...environmentQuery(environment),
...kqlQuery(kuery),
...(serviceName ? termQuery(SERVICE_NAME, serviceName) : []),
...(agentLanguage
? termQuery(SERVICE_LANGUAGE_NAME, agentLanguage)
: []),
],
},
},
aggs: {
sample: {
random_sampler: randomSampler,
aggs: {
services: {
terms: {
field: SERVICE_NAME,
size: MAX_NUMBER_OF_SERVICES,
},
aggs: {
instances: {
cardinality: {
field: SERVICE_NODE_NAME,
},
},
agentVersions: {
terms: {
field: AGENT_VERSION,
},
},
sample: {
top_metrics: {
metrics: [{ field: AGENT_NAME } as const],
sort: {
'@timestamp': 'desc' as const,
},
},
},
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
},
},
},
},
},
},
},
},
});
return (
response.aggregations?.sample.services.buckets.map((bucket) => {
return {
serviceName: bucket.key as string,
environments: bucket.environments.buckets.map(
(env) => env.key as string
),
agentName: bucket.sample.top[0].metrics[AGENT_NAME] as AgentName,
agentVersion: bucket.agentVersions.buckets.map(
(version) => version.key as string
),
// service.node.name is set by the server only if a container.id or host.name are set. Otherwise should be explicitly set by agents.
instances: (bucket.instances.value as number) || 1,
};
}) ?? []
);
}

View file

@ -0,0 +1,119 @@
/*
* 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 { getApmEventClient } from '../../lib/helpers/get_apm_event_client';
import { getRandomSampler } from '../../lib/helpers/get_random_sampler';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import {
environmentRt,
kueryRt,
probabilityRt,
rangeRt,
} from '../default_api_types';
import { getAgents } from './get_agents';
import { getAgentInstances } from './get_agent_instances';
const agentExplorerRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/get_agents_per_service',
options: { tags: ['access:apm'] },
params: t.type({
query: t.intersection([
environmentRt,
kueryRt,
rangeRt,
probabilityRt,
t.partial({
serviceName: t.string,
agentLanguage: t.string,
}),
]),
}),
async handler(resources): Promise<{
items: Array<{
serviceName: string;
environments: string[];
agentName: import('./../../../typings/es_schemas/ui/fields/agent').AgentName;
agentVersion: string[];
agentDocsPageUrl?: string;
instances: number;
}>;
}> {
const {
params,
request,
plugins: { security },
} = resources;
const {
environment,
kuery,
start,
end,
probability,
serviceName,
agentLanguage,
} = params.query;
const [apmEventClient, randomSampler] = await Promise.all([
getApmEventClient(resources),
getRandomSampler({ security, request, probability }),
]);
return getAgents({
environment,
serviceName,
agentLanguage,
kuery,
apmEventClient,
start,
end,
randomSampler,
});
},
});
const agentExplorerInstanceRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/services/{serviceName}/agent_instances',
options: { tags: ['access:apm'] },
params: t.type({
path: t.type({ serviceName: t.string }),
query: t.intersection([environmentRt, kueryRt, rangeRt, probabilityRt]),
}),
async handler(resources): Promise<{
items: Array<{
serviceNode?: string;
environments: string[];
agentVersion: string;
lastReport: string;
}>;
}> {
const { params } = resources;
const { environment, kuery, start, end } = params.query;
const { serviceName } = params.path;
const apmEventClient = await getApmEventClient(resources);
return {
items: await getAgentInstances({
environment,
serviceName,
kuery,
apmEventClient,
start,
end,
}),
};
},
});
export const agentExplorerRouteRepository = {
...agentExplorerRoute,
...agentExplorerInstanceRoute,
};

View file

@ -10,12 +10,13 @@ import type {
ServerRouteRepository,
} from '@kbn/server-route-repository';
import { PickByValue } from 'utility-types';
import { agentExplorerRouteRepository } from '../agent_explorer/route';
import { agentKeysRouteRepository } from '../agent_keys/route';
import { alertsChartPreviewRouteRepository } from '../alerts/route';
import { dependencisRouteRepository } from '../dependencies/route';
import { correlationsRouteRepository } from '../correlations/route';
import { dataViewRouteRepository } from '../data_view/route';
import { debugTelemetryRoute } from '../debug_telemetry/route';
import { dependencisRouteRepository } from '../dependencies/route';
import { environmentsRouteRepository } from '../environments/route';
import { errorsRouteRepository } from '../errors/route';
import { eventMetadataRouteRepository } from '../event_metadata/route';
@ -25,6 +26,7 @@ import { historicalDataRouteRepository } from '../historical_data/route';
import { infrastructureRouteRepository } from '../infrastructure/route';
import { latencyDistributionRouteRepository } from '../latency_distribution/route';
import { metricsRouteRepository } from '../metrics/route';
import { mobileRouteRepository } from '../mobile/route';
import { observabilityOverviewRouteRepository } from '../observability_overview/route';
import { serviceRouteRepository } from '../services/route';
import { serviceGroupRouteRepository } from '../service_groups/route';
@ -33,15 +35,14 @@ import { agentConfigurationRouteRepository } from '../settings/agent_configurati
import { anomalyDetectionRouteRepository } from '../settings/anomaly_detection/route';
import { apmIndicesRouteRepository } from '../settings/apm_indices/route';
import { customLinkRouteRepository } from '../settings/custom_link/route';
import { labsRouteRepository } from '../settings/labs/route';
import { sourceMapsRouteRepository } from '../source_maps/route';
import { spanLinksRouteRepository } from '../span_links/route';
import { storageExplorerRouteRepository } from '../storage_explorer/route';
import { suggestionsRouteRepository } from '../suggestions/route';
import { timeRangeMetadataRoute } from '../time_range_metadata/route';
import { traceRouteRepository } from '../traces/route';
import { transactionRouteRepository } from '../transactions/route';
import { storageExplorerRouteRepository } from '../storage_explorer/route';
import { labsRouteRepository } from '../settings/labs/route';
import { mobileRouteRepository } from '../mobile/route';
function getTypedGlobalApmServerRouteRepository() {
const repository = {
@ -76,6 +77,7 @@ function getTypedGlobalApmServerRouteRepository() {
...debugTelemetryRoute,
...timeRangeMetadataRoute,
...labsRouteRepository,
...agentExplorerRouteRepository,
...mobileRouteRepository,
};

View file

@ -17,7 +17,7 @@ import { ServiceGroup } from '../../../../common/service_groups';
import { RandomSampler } from '../../../lib/helpers/get_random_sampler';
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
const MAX_NUMBER_OF_SERVICES = 500;
export const MAX_NUMBER_OF_SERVICES = 500;
export async function getServicesItems({
environment,

View file

@ -26,6 +26,7 @@ export {
enableInfrastructureHostsView,
enableServiceMetrics,
enableAwsLambdaMetrics,
enableAgentExplorerView,
apmAWSLambdaPriceFactor,
apmAWSLambdaRequestCostPerMillion,
enableCriticalPath,

View file

@ -21,6 +21,7 @@ export const apmLabsButton = 'observability:apmLabsButton';
export const enableInfrastructureHostsView = 'observability:enableInfrastructureHostsView';
export const enableAwsLambdaMetrics = 'observability:enableAwsLambdaMetrics';
export const enableServiceMetrics = 'observability:apmEnableServiceMetrics';
export const enableAgentExplorerView = 'observability:apmAgentExplorerView';
export const apmAWSLambdaPriceFactor = 'observability:apmAWSLambdaPriceFactor';
export const apmAWSLambdaRequestCostPerMillion = 'observability:apmAWSLambdaRequestCostPerMillion';
export const enableCriticalPath = 'observability:apmEnableCriticalPath';

View file

@ -30,6 +30,7 @@ export {
enableNewSyntheticsView,
apmServiceGroupMaxNumberOfServices,
enableInfrastructureHostsView,
enableAgentExplorerView,
} from '../common/ui_settings_keys';
export { uptimeOverviewLocatorID } from '../common';

View file

@ -6,8 +6,8 @@
*/
import { schema } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { UiSettingsParams } from '@kbn/core/types';
import { i18n } from '@kbn/i18n';
import { observabilityFeatureId, ProgressiveLoadingQuality } from '../common';
import {
enableComparisonByDefault,
@ -21,12 +21,13 @@ import {
apmTraceExplorerTab,
apmOperationsTab,
apmLabsButton,
enableInfrastructureHostsView,
enableServiceMetrics,
enableAgentExplorerView,
enableAwsLambdaMetrics,
apmAWSLambdaPriceFactor,
apmAWSLambdaRequestCostPerMillion,
enableCriticalPath,
enableInfrastructureHostsView,
enableServiceMetrics,
} from '../common/ui_settings_keys';
const technicalPreviewLabel = i18n.translate(
@ -294,6 +295,23 @@ export const uiSettings: Record<string, UiSettings> = {
type: 'boolean',
showInLabs: true,
},
[enableAgentExplorerView]: {
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.enableAgentExplorer', {
defaultMessage: 'Agent explorer',
}),
description: i18n.translate('xpack.observability.enableAgentExplorerDescription', {
defaultMessage: '{technicalPreviewLabel} Enables Agent explorer view.',
values: {
technicalPreviewLabel: `<em>[${technicalPreviewLabel}]</em>`,
},
}),
schema: schema.boolean(),
value: false,
requiresPageReload: true,
type: 'boolean',
showInLabs: true,
},
[apmAWSLambdaPriceFactor]: {
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.apmAWSLambdaPricePerGbSeconds', {

View file

@ -5,20 +5,25 @@
* 2.0.
*/
import { FtrConfigProviderContext } from '@kbn/test';
import supertest from 'supertest';
import { format, UrlObject } from 'url';
import {
ApmUsername,
APM_TEST_PASSWORD,
} from '@kbn/apm-plugin/server/test_helpers/create_apm_users/authentication';
import { createApmUsers } from '@kbn/apm-plugin/server/test_helpers/create_apm_users/create_apm_users';
import { InheritedFtrProviderContext, InheritedServices } from './ftr_provider_context';
import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
import { FtrConfigProviderContext } from '@kbn/test';
import supertest from 'supertest';
import { format, UrlObject } from 'url';
import { MachineLearningAPIProvider } from '../../functional/services/ml/api';
import { APMFtrConfigName } from '../configs';
import { createApmApiClient } from './apm_api_supertest';
import { RegistryProvider } from './registry';
import { bootstrapApmSynthtrace } from './bootstrap_apm_synthtrace';
import { MachineLearningAPIProvider } from '../../functional/services/ml/api';
import {
FtrProviderContext,
InheritedFtrProviderContext,
InheritedServices,
} from './ftr_provider_context';
import { RegistryProvider } from './registry';
export interface ApmFtrConfig {
name: APMFtrConfigName;
@ -43,7 +48,37 @@ async function getApmApiClient({
export type CreateTestConfig = ReturnType<typeof createTestConfig>;
export function createTestConfig(config: ApmFtrConfig) {
type ApmApiClientKey =
| 'noAccessUser'
| 'readUser'
| 'writeUser'
| 'annotationWriterUser'
| 'noMlAccessUser'
| 'manageOwnAgentKeysUser'
| 'createAndAllAgentKeysUser'
| 'monitorClusterAndIndicesUser';
export interface CreateTest {
testFiles: string[];
servers: any;
servicesRequiredForTestAnalysis: string[];
services: InheritedServices & {
apmFtrConfig: () => ApmFtrConfig;
registry: ({ getService }: FtrProviderContext) => ReturnType<typeof RegistryProvider>;
synthtraceEsClient: (context: InheritedFtrProviderContext) => Promise<ApmSynthtraceEsClient>;
apmApiClient: (
context: InheritedFtrProviderContext
) => Record<ApmApiClientKey, Awaited<ReturnType<typeof getApmApiClient>>>;
ml: ({ getService }: FtrProviderContext) => ReturnType<typeof MachineLearningAPIProvider>;
};
junit: { reportName: string };
esTestCluster: any;
kbnTestServer: any;
}
export function createTestConfig(
config: ApmFtrConfig
): ({ readConfigFile }: FtrConfigProviderContext) => Promise<CreateTest> {
const { license, name, kibanaConfig } = config;
return async ({ readConfigFile }: FtrConfigProviderContext) => {
@ -51,7 +86,7 @@ export function createTestConfig(config: ApmFtrConfig) {
require.resolve('../../api_integration/config.ts')
);
const services = xPackAPITestsConfig.get('services') as InheritedServices;
const services = xPackAPITestsConfig.get('services');
const servers = xPackAPITestsConfig.get('servers');
const kibanaServer = servers.kibana as UrlObject;
const kibanaServerUrl = format(kibanaServer);

View file

@ -0,0 +1,191 @@
/*
* 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 { apm, timerange } from '@kbn/apm-synthtrace';
import { APIClientRequestParamsOf } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { RecursivePartial } from '@kbn/apm-plugin/typings/common';
import { keyBy } from 'lodash';
import { FtrProviderContext } from '../../common/ftr_provider_context';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const synthtraceEsClient = getService('synthtraceEsClient');
const start = new Date('2021-01-01T00:00:00.000Z').getTime();
const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1;
const goServiceName = 'opbeans-go';
const nodeServiceName = 'opbeans-node';
async function callApi(
overrides?: RecursivePartial<
APIClientRequestParamsOf<'GET /internal/apm/get_agents_per_service'>['params']
>
) {
return await apmApiClient.readUser({
endpoint: 'GET /internal/apm/get_agents_per_service',
params: {
query: {
probability: 1,
environment: 'ENVIRONMENT_ALL',
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
kuery: '',
...overrides?.query,
},
},
});
}
registry.when('Agent explorer when data is not loaded', { config: 'basic', archives: [] }, () => {
it('handles empty state', async () => {
const { status, body } = await callApi();
expect(status).to.be(200);
expect(body.items).to.be.empty();
});
});
registry.when('Agent explorer', { config: 'basic', archives: [] }, () => {
describe('when data is loaded', () => {
before(async () => {
const serviceGo = apm
.service({
name: goServiceName,
environment: 'production',
agentName: 'go',
})
.instance('instance-go')
.defaults({
'agent.version': '5.1.2',
'service.language.name': 'go',
});
const serviceNodeStaging = apm
.service({
name: nodeServiceName,
environment: 'staging',
agentName: 'nodejs',
})
.instance('instance-node-staging')
.defaults({
'agent.version': '1.0.0',
'service.language.name': 'javascript',
});
const serviceNodeDev = apm
.service({
name: nodeServiceName,
environment: 'dev',
agentName: 'nodejs',
})
.instance('instance-node-dev')
.defaults({
'agent.version': '1.0.3',
'service.language.name': 'javascript',
});
await synthtraceEsClient.index([
timerange(start, end)
.interval('5m')
.rate(1)
.generator((timestamp) =>
serviceGo
.transaction({ transactionName: 'GET /api/product/list' })
.duration(2000)
.timestamp(timestamp)
),
timerange(start, end)
.interval('5m')
.rate(1)
.generator((timestamp) =>
serviceNodeStaging
.transaction({ transactionName: 'GET /api/users/list' })
.duration(2000)
.timestamp(timestamp)
),
timerange(start, end)
.interval('5m')
.rate(1)
.generator((timestamp) =>
serviceNodeDev
.transaction({ transactionName: 'GET /api/users/list' })
.duration(2000)
.timestamp(timestamp)
),
]);
});
after(() => synthtraceEsClient.clean());
it('returns correct agents information', async () => {
const { status, body } = await callApi();
expect(status).to.be(200);
expect(body.items).to.have.length(2);
const agents = keyBy(body.items, 'serviceName');
const goAgent = agents[goServiceName];
expect(goAgent?.environments).to.have.length(1);
expect(goAgent?.environments).to.contain('production');
expect(goAgent?.agentName).to.be('go');
expect(goAgent?.agentVersion).to.contain('5.1.2');
expect(goAgent?.agentDocsPageUrl).to.be(
'https://www.elastic.co/guide/en/apm/agent/go/current/'
);
const nodeAgent = agents[nodeServiceName];
expect(nodeAgent?.environments).to.have.length(2);
expect(nodeAgent?.environments).to.contain('staging');
expect(nodeAgent?.environments).to.contain('dev');
expect(nodeAgent?.agentName).to.be('nodejs');
expect(nodeAgent?.agentVersion).to.contain('1.0.0');
expect(nodeAgent?.agentVersion).to.contain('1.0.3');
expect(nodeAgent?.agentDocsPageUrl).to.be(
'https://www.elastic.co/guide/en/apm/agent/nodejs/current/'
);
});
const matchingFilterTests = [
['environment', 'dev', nodeServiceName],
['serviceName', nodeServiceName, nodeServiceName],
['agentLanguage', 'go', goServiceName],
['kuery', `service.name : ${goServiceName}`, goServiceName],
];
matchingFilterTests.forEach(([filterName, filterValue, expectedService]) => {
it(`returns only agents matching selected ${filterName}`, async () => {
const { status, body } = await callApi({
query: {
[filterName]: filterValue,
},
});
expect(status).to.be(200);
expect(body.items).to.have.length(1);
expect(body.items[0]?.serviceName).to.be(expectedService);
});
});
const notMatchingFilterTests = [
['serviceName', 'my-service'],
['agentLanguage', 'my-language'],
];
notMatchingFilterTests.forEach(([filterName, filterValue]) => {
it(`returns empty agents when there is no matching ${filterName}`, async () => {
const { status, body } = await callApi({
query: {
[filterName]: filterValue,
},
});
expect(status).to.be(200);
expect(body.items).to.be.empty();
});
});
});
});
}