[APM] Dependencies table for backend inventory/detail views (#106995)

This commit is contained in:
Dario Gieselaar 2021-07-30 15:23:45 +02:00 committed by GitHub
parent 2a9d17c317
commit efd254047d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1630 additions and 993 deletions

View file

@ -0,0 +1,67 @@
/*
* 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 { Coordinate } from '../typings/timeseries';
export enum NodeType {
service = 'service',
backend = 'backend',
}
interface NodeBase {
id: string;
}
export interface ServiceNode extends NodeBase {
type: NodeType.service;
serviceName: string;
agentName: AgentName;
environment: string;
}
export interface BackendNode extends NodeBase {
type: NodeType.backend;
backendName: string;
spanType: string;
spanSubtype: string;
}
export type Node = ServiceNode | BackendNode;
export interface ConnectionStatsItem {
location: Node;
stats: {
latency: {
value: number | null;
timeseries: Coordinate[];
};
throughput: {
value: number | null;
timeseries: Coordinate[];
};
errorRate: {
value: number | null;
timeseries: Coordinate[];
};
};
}
export interface ConnectionStatsItemWithImpact extends ConnectionStatsItem {
stats: ConnectionStatsItem['stats'] & {
impact: number;
};
}
export interface ConnectionStatsItemWithComparisonData {
location: Node;
currentStats: ConnectionStatsItemWithImpact['stats'];
previousStats: ConnectionStatsItemWithImpact['stats'] | null;
}
export function getNodeName(node: Node) {
return node.type === NodeType.service ? node.serviceName : node.backendName;
}

View file

@ -7,9 +7,21 @@
import moment from 'moment';
import { parseInterval } from '../../../../../src/plugins/data/common';
export function getOffsetInMs(start: number, offset?: string) {
export function getOffsetInMs({
start,
end,
offset,
}: {
start: number;
end: number;
offset?: string;
}) {
if (!offset) {
return 0;
return {
startWithOffset: start,
endWithOffset: end,
offsetInMs: 0,
};
}
const interval = parseInterval(offset);
@ -20,5 +32,9 @@ export function getOffsetInMs(start: number, offset?: string) {
const calculatedOffset = start - moment(start).subtract(interval).valueOf();
return calculatedOffset;
return {
startWithOffset: start - calculatedOffset,
endWithOffset: end - calculatedOffset,
offsetInMs: calculatedOffset,
};
}

View file

@ -0,0 +1,104 @@
/*
* 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 { i18n } from '@kbn/i18n';
import React from 'react';
import { getNodeName, NodeType } from '../../../../common/connections';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useUrlParams } from '../../../context/url_params_context/use_url_params';
import { useFetcher } from '../../../hooks/use_fetcher';
import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison';
import { DependenciesTable } from '../../shared/dependencies_table';
import { useApmBackendContext } from '../../../context/apm_backend/use_apm_backend_context';
import { ServiceLink } from '../../shared/service_link';
export function BackendDetailDependenciesTable() {
const {
urlParams: { start, end, environment, comparisonEnabled, comparisonType },
} = useUrlParams();
const {
query: { rangeFrom, rangeTo, kuery },
} = useApmParams('/backends/:backendName/overview');
const { offset } = getTimeRangeComparison({
start,
end,
comparisonEnabled,
comparisonType,
});
const { backendName } = useApmBackendContext();
const { data, status } = useFetcher(
(callApmApi) => {
if (!start || !end) {
return;
}
return callApmApi({
endpoint: 'GET /api/apm/backends/{backendName}/upstream_services',
params: {
path: {
backendName,
},
query: { start, end, environment, numBuckets: 20, offset },
},
});
},
[start, end, environment, offset, backendName]
);
const dependencies =
data?.services.map((dependency) => {
const { location } = dependency;
const name = getNodeName(location);
if (location.type !== NodeType.service) {
throw new Error('Expected a service node');
}
return {
name,
currentStats: dependency.currentStats,
previousStats: dependency.previousStats,
link: (
<ServiceLink
serviceName={location.serviceName}
agentName={location.agentName}
query={{
comparisonEnabled: comparisonEnabled ? 'true' : 'false',
comparisonType,
environment,
kuery,
rangeFrom,
rangeTo,
latencyAggregationType: undefined,
transactionType: undefined,
}}
/>
),
};
}) ?? [];
return (
<DependenciesTable
dependencies={dependencies}
title={i18n.translate('xpack.apm.backendDetail.dependenciesTableTitle', {
defaultMessage: 'Upstream services',
})}
nameColumnTitle={i18n.translate(
'xpack.apm.backendDetail.dependenciesTableColumnBackend',
{
defaultMessage: 'Service',
}
)}
status={status}
compact={false}
/>
);
}

View file

@ -8,6 +8,7 @@ import { EuiFlexItem } from '@elastic/eui';
import { EuiPanel } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import React from 'react';
import { EuiSpacer } from '@elastic/eui';
import { ApmBackendContextProvider } from '../../../context/apm_backend/apm_backend_context';
import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb';
import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context';
@ -17,6 +18,7 @@ import { ApmMainTemplate } from '../../routing/templates/apm_main_template';
import { SearchBar } from '../../shared/search_bar';
import { BackendLatencyChart } from './backend_latency_chart';
import { BackendInventoryTitle } from '../../routing/home';
import { BackendDetailDependenciesTable } from './backend_detail_dependencies_table';
export function BackendDetailOverview() {
const {
@ -53,6 +55,8 @@ export function BackendDetailOverview() {
</EuiFlexItem>
</EuiFlexGroup>
</ChartPointerEventContextProvider>
<EuiSpacer size="m" />
<BackendDetailDependenciesTable />
</ApmBackendContextProvider>
</ApmMainTemplate>
);

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { getNodeName, NodeType } from '../../../../../common/connections';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
import { useFetcher } from '../../../../hooks/use_fetcher';
import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time_range_comparison';
import { DependenciesTable } from '../../../shared/dependencies_table';
import { BackendLink } from '../../../shared/backend_link';
export function BackendInventoryDependenciesTable() {
const {
urlParams: { start, end, environment, comparisonEnabled, comparisonType },
} = useUrlParams();
const {
query: { rangeFrom, rangeTo, kuery },
} = useApmParams('/backends');
const { offset } = getTimeRangeComparison({
start,
end,
comparisonEnabled,
comparisonType,
});
const { data, status } = useFetcher(
(callApmApi) => {
if (!start || !end) {
return;
}
return callApmApi({
endpoint: 'GET /api/apm/backends/top_backends',
params: {
query: { start, end, environment, numBuckets: 20, offset },
},
});
},
[start, end, environment, offset]
);
const dependencies =
data?.backends.map((dependency) => {
const { location } = dependency;
const name = getNodeName(location);
if (location.type !== NodeType.backend) {
throw new Error('Expected a backend node');
}
const link = (
<BackendLink
backendName={location.backendName}
type={location.spanType}
subtype={location.spanSubtype}
query={{
comparisonEnabled: comparisonEnabled ? 'true' : 'false',
comparisonType,
environment,
kuery,
rangeFrom,
rangeTo,
}}
/>
);
return {
name,
currentStats: dependency.currentStats,
previousStats: dependency.previousStats,
link,
};
}) ?? [];
return (
<DependenciesTable
dependencies={dependencies}
title={i18n.translate(
'xpack.apm.backendInventory.dependenciesTableTitle',
{
defaultMessage: 'Backends',
}
)}
nameColumnTitle={i18n.translate(
'xpack.apm.backendInventory.dependenciesTableColumnBackend',
{
defaultMessage: 'Backend',
}
)}
status={status}
compact={false}
/>
);
}

View file

@ -7,7 +7,13 @@
import React from 'react';
import { SearchBar } from '../../shared/search_bar';
import { BackendInventoryDependenciesTable } from './backend_inventory_dependencies_table';
export function BackendInventory() {
return <SearchBar showTimeComparison />;
return (
<>
<SearchBar showTimeComparison />
<BackendInventoryDependenciesTable />
</>
);
}

View file

@ -95,9 +95,7 @@ export function ServiceOverview() {
{!isRumAgent && (
<EuiFlexItem grow={7}>
<EuiPanel hasBorder={true}>
<ServiceOverviewDependenciesTable
serviceName={serviceName}
/>
<ServiceOverviewDependenciesTable />
</EuiPanel>
</EuiFlexItem>
)}

View file

@ -107,7 +107,9 @@ describe('ServiceOverview', () => {
totalTransactionGroups: 0,
isAggregationAccurate: true,
},
'GET /api/apm/services/{serviceName}/dependencies': [],
'GET /api/apm/services/{serviceName}/dependencies': {
serviceDependencies: [],
},
'GET /api/apm/services/{serviceName}/service_overview_instances/main_statistics': [],
'GET /api/apm/services/{serviceName}/transactions/charts/latency': {
currentPeriod: {

View file

@ -5,249 +5,43 @@
* 2.0.
*/
import {
EuiBasicTableColumn,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { keyBy } from 'lodash';
import React from 'react';
import { getNextEnvironmentUrlParam } from '../../../../../common/environment_filter_values';
import {
asMillisecondDuration,
asPercent,
asTransactionRate,
} from '../../../../../common/utils/formatters';
import { offsetPreviousPeriodCoordinates } from '../../../../../common/utils/offset_previous_period_coordinate';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ServiceDependencyItem } from '../../../../../server/lib/services/get_service_dependencies';
import { Coordinate } from '../../../../../typings/timeseries';
import { getNodeName, NodeType } from '../../../../../common/connections';
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { unit } from '../../../../utils/style';
import { useFetcher } from '../../../../hooks/use_fetcher';
import { BackendLink } from '../../../shared/backend_link';
import { SparkPlot } from '../../../shared/charts/spark_plot';
import { ImpactBar } from '../../../shared/ImpactBar';
import { ServiceMapLink } from '../../../shared/Links/apm/ServiceMapLink';
import { DependenciesTable } from '../../../shared/dependencies_table';
import { ServiceLink } from '../../../shared/service_link';
import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper';
import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time_range_comparison';
import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
import { ServiceOverviewTableContainer } from '../service_overview_table_container';
interface Props {
serviceName: string;
}
type ServiceDependencyPeriods = ServiceDependencyItem & {
latency: { previousPeriodTimeseries?: Coordinate[] };
throughput: { previousPeriodTimeseries?: Coordinate[] };
errorRate: { previousPeriodTimeseries?: Coordinate[] };
previousPeriodImpact?: number;
};
function mergeCurrentAndPreviousPeriods({
currentPeriod = [],
previousPeriod = [],
}: {
currentPeriod?: ServiceDependencyItem[];
previousPeriod?: ServiceDependencyItem[];
}): ServiceDependencyPeriods[] {
const previousPeriodMap = keyBy(previousPeriod, 'name');
return currentPeriod.map((currentDependency) => {
const previousDependency = previousPeriodMap[currentDependency.name];
if (!previousDependency) {
return currentDependency;
}
return {
...currentDependency,
latency: {
...currentDependency.latency,
previousPeriodTimeseries: offsetPreviousPeriodCoordinates({
currentPeriodTimeseries: currentDependency.latency.timeseries,
previousPeriodTimeseries: previousDependency.latency?.timeseries,
}),
},
throughput: {
...currentDependency.throughput,
previousPeriodTimeseries: offsetPreviousPeriodCoordinates({
currentPeriodTimeseries: currentDependency.throughput.timeseries,
previousPeriodTimeseries: previousDependency.throughput?.timeseries,
}),
},
errorRate: {
...currentDependency.errorRate,
previousPeriodTimeseries: offsetPreviousPeriodCoordinates({
currentPeriodTimeseries: currentDependency.errorRate.timeseries,
previousPeriodTimeseries: previousDependency.errorRate?.timeseries,
}),
},
previousPeriodImpact: previousDependency.impact,
};
});
}
export function ServiceOverviewDependenciesTable({ serviceName }: Props) {
export function ServiceOverviewDependenciesTable() {
const {
urlParams: { start, end, environment, comparisonEnabled, comparisonType },
urlParams: {
start,
end,
environment,
comparisonEnabled,
comparisonType,
latencyAggregationType,
},
} = useUrlParams();
const { query } = useApmParams('/services/:serviceName/overview');
const {
query: { kuery, rangeFrom, rangeTo },
} = useApmParams('/services/:serviceName/overview');
const { comparisonStart, comparisonEnd } = getTimeRangeComparison({
const { offset } = getTimeRangeComparison({
start,
end,
comparisonEnabled,
comparisonType,
});
const columns: Array<EuiBasicTableColumn<ServiceDependencyPeriods>> = [
{
field: 'name',
name: i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableColumnBackend',
{
defaultMessage: 'Backend',
}
),
render: (_, item) => {
return (
<TruncateWithTooltip
text={item.name}
content={
item.type === 'service' ? (
<ServiceLink
agentName={item.agentName}
query={{
...query,
environment: getNextEnvironmentUrlParam({
requestedEnvironment: item.environment,
currentEnvironmentUrlParam: query.environment,
}),
}}
serviceName={item.serviceName}
/>
) : (
<BackendLink
backendName={item.name}
query={query}
subtype={item.spanSubtype}
type={item.spanType}
/>
)
}
/>
);
},
sortable: true,
},
{
field: 'latencyValue',
name: i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableColumnLatency',
{
defaultMessage: 'Latency (avg.)',
}
),
width: `${unit * 10}px`,
render: (_, { latency }) => {
return (
<SparkPlot
color="euiColorVis1"
series={latency.timeseries}
comparisonSeries={
comparisonEnabled ? latency.previousPeriodTimeseries : undefined
}
valueLabel={asMillisecondDuration(latency.value)}
/>
);
},
sortable: true,
},
{
field: 'throughputValue',
name: i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableColumnThroughput',
{ defaultMessage: 'Throughput' }
),
width: `${unit * 10}px`,
render: (_, { throughput }) => {
return (
<SparkPlot
compact
color="euiColorVis0"
series={throughput.timeseries}
comparisonSeries={
comparisonEnabled
? throughput.previousPeriodTimeseries
: undefined
}
valueLabel={asTransactionRate(throughput.value)}
/>
);
},
sortable: true,
},
{
field: 'errorRateValue',
name: i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableColumnErrorRate',
{
defaultMessage: 'Error rate',
}
),
width: `${unit * 10}px`,
render: (_, { errorRate }) => {
return (
<SparkPlot
compact
color="euiColorVis7"
series={errorRate.timeseries}
comparisonSeries={
comparisonEnabled ? errorRate.previousPeriodTimeseries : undefined
}
valueLabel={asPercent(errorRate.value, 1)}
/>
);
},
sortable: true,
},
{
field: 'impactValue',
name: i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableColumnImpact',
{
defaultMessage: 'Impact',
}
),
width: `${unit * 5}px`,
render: (_, { impact, previousPeriodImpact }) => {
return (
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiFlexItem>
<ImpactBar value={impact} size="m" />
</EuiFlexItem>
{comparisonEnabled && previousPeriodImpact !== undefined && (
<EuiFlexItem>
<ImpactBar
value={previousPeriodImpact}
size="s"
color="subdued"
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
},
sortable: true,
},
];
// Fetches current period dependencies
const { serviceName, transactionType } = useApmServiceContext();
const { data, status } = useFetcher(
(callApmApi) => {
if (!start || !end) {
@ -258,108 +52,74 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) {
endpoint: 'GET /api/apm/services/{serviceName}/dependencies',
params: {
path: { serviceName },
query: { start, end, environment, numBuckets: 20 },
query: { start, end, environment, numBuckets: 20, offset },
},
});
},
[start, end, serviceName, environment]
[start, end, serviceName, environment, offset]
);
// Fetches previous period dependencies
const { data: previousPeriodData, status: previousPeriodStatus } = useFetcher(
(callApmApi) => {
if (!comparisonStart || !comparisonEnd) {
return;
}
const dependencies =
data?.serviceDependencies.map((dependency) => {
const { location } = dependency;
const name = getNodeName(location);
const link =
location.type === NodeType.backend ? (
<BackendLink
backendName={location.backendName}
type={location.spanType}
subtype={location.spanSubtype}
query={{
comparisonEnabled: comparisonEnabled ? 'true' : 'false',
comparisonType,
environment,
kuery,
rangeFrom,
rangeTo,
}}
/>
) : (
<ServiceLink
serviceName={location.serviceName}
agentName={location.agentName}
query={{
comparisonEnabled: comparisonEnabled ? 'true' : 'false',
comparisonType,
environment,
kuery,
rangeFrom,
rangeTo,
latencyAggregationType,
transactionType,
}}
/>
);
return callApmApi({
endpoint: 'GET /api/apm/services/{serviceName}/dependencies',
params: {
path: { serviceName },
query: {
start: comparisonStart,
end: comparisonEnd,
environment,
numBuckets: 20,
},
},
});
},
[comparisonStart, comparisonEnd, serviceName, environment]
);
const serviceDependencies = mergeCurrentAndPreviousPeriods({
currentPeriod: data?.serviceDependencies,
previousPeriod: previousPeriodData?.serviceDependencies,
});
// need top-level sortable fields for the managed table
const items = serviceDependencies.map((item) => ({
...item,
errorRateValue: item.errorRate.value,
latencyValue: item.latency.value,
throughputValue: item.throughput.value,
impactValue: item.impact,
}));
return {
name,
currentStats: dependency.currentStats,
previousStats: dependency.previousStats,
link,
};
}) ?? [];
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup responsive={false} justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableTitle',
{
defaultMessage: 'Dependencies',
}
)}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ServiceMapLink serviceName={serviceName}>
{i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableLinkText',
{
defaultMessage: 'View service map',
}
)}
</ServiceMapLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<TableFetchWrapper status={status}>
<ServiceOverviewTableContainer
isEmptyAndLoading={
items.length === 0 && status === FETCH_STATUS.LOADING
}
>
<EuiInMemoryTable
columns={columns}
items={items}
allowNeutralSort={false}
loading={
status === FETCH_STATUS.LOADING ||
previousPeriodStatus === FETCH_STATUS.LOADING
}
pagination={{
initialPageSize: 5,
pageSizeOptions: [5],
hidePerPageOptions: true,
}}
sorting={{
sort: {
direction: 'desc',
field: 'impactValue',
},
}}
/>
</ServiceOverviewTableContainer>
</TableFetchWrapper>
</EuiFlexItem>
</EuiFlexGroup>
<DependenciesTable
dependencies={dependencies}
title={i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableTitle',
{
defaultMessage: 'Dependencies',
}
)}
nameColumnTitle={i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableColumnBackend',
{
defaultMessage: 'Backend',
}
)}
serviceName={serviceName}
status={status}
/>
);
}

View file

@ -22,7 +22,7 @@ import { APIReturnType } from '../../../../services/rest/createCallApmApi';
import { ErrorOverviewLink } from '../../../shared/Links/apm/ErrorOverviewLink';
import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper';
import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time_range_comparison';
import { ServiceOverviewTableContainer } from '../service_overview_table_container';
import { OverviewTableContainer } from '../../../shared/overview_table_container';
import { getColumns } from './get_column';
interface Props {
@ -205,7 +205,7 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) {
</EuiFlexItem>
<EuiFlexItem>
<TableFetchWrapper status={status}>
<ServiceOverviewTableContainer
<OverviewTableContainer
isEmptyAndLoading={
totalItems === 0 && status === FETCH_STATUS.LOADING
}
@ -245,7 +245,7 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) {
sort,
}}
/>
</ServiceOverviewTableContainer>
</OverviewTableContainer>
</TableFetchWrapper>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -24,7 +24,7 @@ import {
SortDirection,
SortField,
} from '../service_overview_instances_chart_and_table';
import { ServiceOverviewTableContainer } from '../service_overview_table_container';
import { OverviewTableContainer } from '../../../shared/overview_table_container';
import { getColumns } from './get_columns';
import { InstanceDetails } from './intance_details';
@ -140,7 +140,7 @@ export function ServiceOverviewInstancesTable({
</EuiFlexItem>
<EuiFlexItem data-test-subj="serviceInstancesTableContainer">
<TableFetchWrapper status={status}>
<ServiceOverviewTableContainer
<OverviewTableContainer
isEmptyAndLoading={mainStatsItemCount === 0 && isLoading}
>
<EuiBasicTable
@ -154,7 +154,7 @@ export function ServiceOverviewInstancesTable({
itemId="serviceNodeName"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
/>
</ServiceOverviewTableContainer>
</OverviewTableContainer>
</TableFetchWrapper>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -22,7 +22,7 @@ import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { TransactionOverviewLink } from '../../../shared/Links/apm/transaction_overview_link';
import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper';
import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time_range_comparison';
import { ServiceOverviewTableContainer } from '../service_overview_table_container';
import { OverviewTableContainer } from '../../../shared/overview_table_container';
import { getColumns } from './get_columns';
type ApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/main_statistics'>;
@ -226,7 +226,7 @@ export function ServiceOverviewTransactionsTable() {
<EuiFlexItem>
<EuiFlexItem>
<TableFetchWrapper status={status}>
<ServiceOverviewTableContainer
<OverviewTableContainer
isEmptyAndLoading={transactionGroupsTotalItems === 0 && isLoading}
>
<EuiBasicTable
@ -252,7 +252,7 @@ export function ServiceOverviewTransactionsTable() {
});
}}
/>
</ServiceOverviewTableContainer>
</OverviewTableContainer>
</TableFetchWrapper>
</EuiFlexItem>
</EuiFlexItem>

View file

@ -20,15 +20,6 @@ import { settings } from './settings';
* creates the routes.
*/
const apmRoutes = route([
{
path: '/',
element: (
<Breadcrumb title="APM" href="/">
<Outlet />
</Breadcrumb>
),
children: [settings, serviceDetail, home],
},
{
path: '/link-to/transaction/:transactionId',
element: <TransactionLink />,
@ -63,6 +54,15 @@ const apmRoutes = route([
}),
]),
},
{
path: '/',
element: (
<Breadcrumb title="APM" href="/">
<Outlet />
</Breadcrumb>
),
children: [settings, serviceDetail, home],
},
] as const);
export type ApmRoutes = typeof apmRoutes;

View file

@ -84,6 +84,12 @@ export const home = {
{
path: '/backends',
element: <Outlet />,
params: t.partial({
query: t.partial({
comparisonEnabled: t.string,
comparisonType: t.string,
}),
}),
children: [
{
path: '/:backendName/overview',

View file

@ -6,17 +6,19 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui';
import { TypeOf } from '@kbn/typed-react-router-config';
import React from 'react';
import { euiStyled } from '../../../../../../src/plugins/kibana_react/common';
import { useApmRouter } from '../../hooks/use_apm_router';
import { truncate } from '../../utils/style';
import { ApmRoutes } from '../routing/apm_route_config';
import { SpanIcon } from './span_icon';
const StyledLink = euiStyled(EuiLink)`${truncate('100%')};`;
interface BackendLinkProps {
backendName: string;
query: Record<string, string | undefined>;
query?: TypeOf<ApmRoutes, '/backends/:backendName/overview'>['query'];
subtype?: string;
type?: string;
}

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 {
EuiBasicTableColumn,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ConnectionStatsItemWithComparisonData } from '../../../../common/connections';
import {
asMillisecondDuration,
asPercent,
asTransactionRate,
} from '../../../../common/utils/formatters';
import { FETCH_STATUS } from '../../../hooks/use_fetcher';
import { unit } from '../../../utils/style';
import { SparkPlot } from '../charts/spark_plot';
import { ImpactBar } from '../ImpactBar';
import { ServiceMapLink } from '../Links/apm/ServiceMapLink';
import { TableFetchWrapper } from '../table_fetch_wrapper';
import { TruncateWithTooltip } from '../truncate_with_tooltip';
import { OverviewTableContainer } from '../overview_table_container';
export type DependenciesItem = Omit<
ConnectionStatsItemWithComparisonData,
'location'
> & {
name: string;
link: React.ReactElement;
};
interface Props {
dependencies: DependenciesItem[];
serviceName?: string;
title: React.ReactNode;
nameColumnTitle: React.ReactNode;
status: FETCH_STATUS;
compact?: boolean;
}
export function DependenciesTable(props: Props) {
const {
dependencies,
serviceName,
title,
nameColumnTitle,
status,
compact = true,
} = props;
const pagination = compact
? {
initialPageSize: 5,
pageSizeOptions: [5],
hidePerPageOptions: true,
}
: {};
const columns: Array<EuiBasicTableColumn<DependenciesItem>> = [
{
field: 'name',
name: nameColumnTitle,
render: (_, item) => {
const { name, link } = item;
return <TruncateWithTooltip text={name} content={link} />;
},
sortable: true,
},
{
field: 'latencyValue',
name: i18n.translate('xpack.apm.dependenciesTable.columnLatency', {
defaultMessage: 'Latency (avg.)',
}),
width: `${unit * 10}px`,
render: (_, { currentStats, previousStats }) => {
return (
<SparkPlot
color="euiColorVis1"
series={currentStats.latency.timeseries}
comparisonSeries={previousStats?.latency.timeseries}
valueLabel={asMillisecondDuration(currentStats.latency.value)}
/>
);
},
sortable: true,
},
{
field: 'throughputValue',
name: i18n.translate('xpack.apm.dependenciesTable.columnThroughput', {
defaultMessage: 'Throughput',
}),
width: `${unit * 10}px`,
render: (_, { currentStats, previousStats }) => {
return (
<SparkPlot
compact
color="euiColorVis0"
series={currentStats.throughput.timeseries}
comparisonSeries={previousStats?.throughput.timeseries}
valueLabel={asTransactionRate(currentStats.throughput.value)}
/>
);
},
sortable: true,
},
{
field: 'errorRateValue',
name: i18n.translate('xpack.apm.dependenciesTable.columnErrorRate', {
defaultMessage: 'Error rate',
}),
width: `${unit * 10}px`,
render: (_, { currentStats, previousStats }) => {
return (
<SparkPlot
compact
color="euiColorVis7"
series={currentStats.errorRate.timeseries}
comparisonSeries={previousStats?.errorRate.timeseries}
valueLabel={asPercent(currentStats.errorRate.value, 1)}
/>
);
},
sortable: true,
},
{
field: 'impactValue',
name: i18n.translate('xpack.apm.dependenciesTable.columnImpact', {
defaultMessage: 'Impact',
}),
width: `${unit * 5}px`,
render: (_, { currentStats, previousStats }) => {
return (
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiFlexItem>
<ImpactBar value={currentStats.impact} size="m" />
</EuiFlexItem>
{previousStats?.impact !== undefined && (
<EuiFlexItem>
<ImpactBar
value={previousStats?.impact}
size="s"
color="subdued"
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
},
sortable: true,
},
];
// need top-level sortable fields for the managed table
const items =
dependencies.map((item) => ({
...item,
errorRateValue: item.currentStats.errorRate.value,
latencyValue: item.currentStats.latency.value,
throughputValue: item.currentStats.throughput.value,
impactValue: item.currentStats.impact,
})) ?? [];
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup responsive={false} justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>{title}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ServiceMapLink serviceName={serviceName}>
{i18n.translate(
'xpack.apm.dependenciesTable.serviceMapLinkText',
{
defaultMessage: 'View service map',
}
)}
</ServiceMapLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<TableFetchWrapper status={status}>
<OverviewTableContainer
isEmptyAndLoading={
items.length === 0 && status === FETCH_STATUS.LOADING
}
>
<EuiInMemoryTable
columns={columns}
items={items}
allowNeutralSort={false}
loading={status === FETCH_STATUS.LOADING}
pagination={pagination}
sorting={{
sort: {
direction: 'desc',
field: 'impactValue',
},
}}
/>
</OverviewTableContainer>
</TableFetchWrapper>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -10,7 +10,7 @@ import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'
import { useBreakPoints } from '../../../hooks/use_break_points';
/**
* The height for a table on the overview page. Is the height of a 5-row basic
* The height for a table on a overview page. Is the height of a 5-row basic
* table.
*/
const tableHeight = 282;
@ -24,7 +24,7 @@ const tableHeight = 282;
*
* Hide the empty message when we don't yet have any items and are still loading.
*/
const ServiceOverviewTableContainerDiv = euiStyled.div<{
const OverviewTableContainerDiv = euiStyled.div<{
isEmptyAndLoading: boolean;
shouldUseMobileLayout: boolean;
}>`
@ -52,7 +52,7 @@ const ServiceOverviewTableContainerDiv = euiStyled.div<{
}
`;
export function ServiceOverviewTableContainer({
export function OverviewTableContainer({
children,
isEmptyAndLoading,
}: {
@ -62,11 +62,11 @@ export function ServiceOverviewTableContainer({
const { isMedium } = useBreakPoints();
return (
<ServiceOverviewTableContainerDiv
<OverviewTableContainerDiv
isEmptyAndLoading={isEmptyAndLoading}
shouldUseMobileLayout={isMedium}
>
{children}
</ServiceOverviewTableContainerDiv>
</OverviewTableContainerDiv>
);
}

View file

@ -7,17 +7,19 @@
import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui';
import React from 'react';
import { TypeOf } from '@kbn/typed-react-router-config';
import { euiStyled } from '../../../../../../src/plugins/kibana_react/common';
import { truncate } from '../../utils/style';
import { useApmRouter } from '../../hooks/use_apm_router';
import { AgentIcon } from './agent_icon';
import { AgentName } from '../../../typings/es_schemas/ui/fields/agent';
import { ApmRoutes } from '../routing/apm_route_config';
const StyledLink = euiStyled(EuiLink)`${truncate('100%')};`;
interface ServiceLinkProps {
agentName?: AgentName;
query: Record<string, string | undefined>;
query?: TypeOf<ApmRoutes, '/services/:serviceName/overview'>['query'];
serviceName: string;
}

View file

@ -36,9 +36,11 @@ export async function getLatencyChartsForBackend({
}) {
const { apmEventClient } = setup;
const offsetInMs = getOffsetInMs(start, offset);
const startWithOffset = start - offsetInMs;
const endWithOffset = end - offsetInMs;
const { offsetInMs, startWithOffset, endWithOffset } = getOffsetInMs({
start,
end,
offset,
});
const response = await apmEventClient.search('get_latency_for_backend', {
apm: {

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { environmentQuery } from '../../../common/utils/environment_query';
import { getConnectionStats } from '../connections/get_connection_stats';
import { getConnectionStatsItemsWithRelativeImpact } from '../connections/get_connection_stats/get_connection_stats_items_with_relative_impact';
import { NodeType } from '../../../common/connections';
import { Setup } from '../helpers/setup_request';
export async function getTopBackends({
setup,
start,
end,
numBuckets,
environment,
offset,
}: {
setup: Setup;
start: number;
end: number;
numBuckets: number;
environment?: string;
offset?: string;
}) {
const statsItems = await getConnectionStats({
setup,
start,
end,
numBuckets,
filter: [...environmentQuery(environment)],
offset,
collapseBy: 'downstream',
});
return getConnectionStatsItemsWithRelativeImpact(
statsItems.filter((item) => item.location.type !== NodeType.service)
);
}

View file

@ -0,0 +1,45 @@
/*
* 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 { SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../common/elasticsearch_fieldnames';
import { environmentQuery } from '../../../common/utils/environment_query';
import { getConnectionStats } from '../connections/get_connection_stats';
import { getConnectionStatsItemsWithRelativeImpact } from '../connections/get_connection_stats/get_connection_stats_items_with_relative_impact';
import { Setup } from '../helpers/setup_request';
export async function getUpstreamServicesForBackend({
setup,
start,
end,
backendName,
numBuckets,
environment,
offset,
}: {
setup: Setup;
start: number;
end: number;
backendName: string;
numBuckets: number;
environment?: string;
offset?: string;
}) {
const statsItems = await getConnectionStats({
setup,
start,
end,
filter: [
{ term: { [SPAN_DESTINATION_SERVICE_RESOURCE]: backendName } },
...environmentQuery(environment),
],
collapseBy: 'upstream',
numBuckets,
offset,
});
return getConnectionStatsItemsWithRelativeImpact(statsItems);
}

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import { AGENT_NAME } from '../../../common/elasticsearch_fieldnames';
import { RUM_AGENT_NAMES } from '../../../common/agent_name';
// exclude RUM exit spans, as they're high cardinality and don't usually
// talk to databases directly
export function excludeRumExitSpansQuery() {
return [
{
bool: {
must_not: [
{
terms: {
[AGENT_NAME]: RUM_AGENT_NAMES,
},
},
],
},
},
] as QueryDslQueryContainer[];
}

View file

@ -0,0 +1,46 @@
/*
* 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 { isFiniteNumber } from '../../../../common/utils/is_finite_number';
import {
ConnectionStatsItem,
ConnectionStatsItemWithImpact,
} from '../../../../common/connections';
export function getConnectionStatsItemsWithRelativeImpact(
items: ConnectionStatsItem[]
) {
const latencySums = items
.map(
({ stats }) => (stats.latency.value ?? 0) * (stats.throughput.value ?? 0)
)
.filter(isFiniteNumber);
const minLatencySum = Math.min(...latencySums);
const maxLatencySum = Math.max(...latencySums);
const itemsWithImpact: ConnectionStatsItemWithImpact[] = items.map((item) => {
const { stats } = item;
const impact =
isFiniteNumber(stats.latency.value) &&
isFiniteNumber(stats.throughput.value)
? ((stats.latency.value * stats.throughput.value - minLatencySum) /
(maxLatencySum - minLatencySum)) *
100
: 0;
return {
...item,
stats: {
...stats,
impact,
},
};
});
return itemsWithImpact;
}

View file

@ -0,0 +1,227 @@
/*
* 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import objectHash from 'object-hash';
import { getOffsetInMs } from '../../../../common/utils/get_offset_in_ms';
import { ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values';
import { asMutableArray } from '../../../../common/utils/as_mutable_array';
import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent';
import {
AGENT_NAME,
EVENT_OUTCOME,
PARENT_ID,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
SPAN_ID,
SPAN_SUBTYPE,
SPAN_TYPE,
} from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { rangeQuery } from '../../../../../observability/server';
import { Setup } from '../../helpers/setup_request';
import { withApmSpan } from '../../../utils/with_apm_span';
import { Node, NodeType } from '../../../../common/connections';
import { excludeRumExitSpansQuery } from '../exclude_rum_exit_spans_query';
type Destination = {
backendName: string;
spanId: string;
spanType: string;
spanSubtype: string;
} & (
| {}
| {
serviceName: string;
agentName: AgentName;
environment: string;
}
);
// This operation tries to find a service for a backend, by:
// - getting a span for each value of span.destination.service.resource (which indicates an outgoing call)
// - for each span, find the transaction it creates
// - if there is a transaction, match the backend name (span.destination.service.resource) to a service
export const getDestinationMap = ({
setup,
start,
end,
filter,
offset,
}: {
setup: Setup;
start: number;
end: number;
filter: QueryDslQueryContainer[];
offset?: string;
}) => {
return withApmSpan('get_destination_map', async () => {
const { apmEventClient } = setup;
const { startWithOffset, endWithOffset } = getOffsetInMs({
start,
end,
offset,
});
const response = await apmEventClient.search('get_exit_span_samples', {
apm: {
events: [ProcessorEvent.span],
},
body: {
size: 0,
query: {
bool: {
filter: [
{ exists: { field: SPAN_DESTINATION_SERVICE_RESOURCE } },
...rangeQuery(startWithOffset, endWithOffset),
...filter,
...excludeRumExitSpansQuery(),
],
},
},
aggs: {
connections: {
composite: {
size: 10000,
sources: asMutableArray([
{
backendName: {
terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE },
},
},
// make sure we get samples for both successful
// and failed calls
{ eventOutcome: { terms: { field: EVENT_OUTCOME } } },
] as const),
},
aggs: {
sample: {
top_metrics: {
size: 1,
metrics: asMutableArray([
{ field: SPAN_TYPE },
{ field: SPAN_SUBTYPE },
{ field: SPAN_ID },
] as const),
sort: [
{
'@timestamp': 'desc' as const,
},
],
},
},
},
},
},
},
});
const destinationsBySpanId = new Map<string, Destination>();
response.aggregations?.connections.buckets.forEach((bucket) => {
const sample = bucket.sample.top[0].metrics;
const spanId = sample[SPAN_ID] as string;
destinationsBySpanId.set(spanId, {
backendName: bucket.key.backendName as string,
spanId,
spanType: (sample[SPAN_TYPE] as string | null) || '',
spanSubtype: (sample[SPAN_SUBTYPE] as string | null) || '',
});
});
const transactionResponse = await apmEventClient.search(
'get_transactions_for_exit_spans',
{
apm: {
events: [ProcessorEvent.transaction],
},
body: {
query: {
bool: {
filter: [
{
terms: {
[PARENT_ID]: Array.from(destinationsBySpanId.keys()),
},
},
// add a 5m buffer at the end of the time range for long running spans
...rangeQuery(
startWithOffset,
endWithOffset + 1000 * 1000 * 60 * 5
),
],
},
},
size: destinationsBySpanId.size,
fields: asMutableArray([
SERVICE_NAME,
SERVICE_ENVIRONMENT,
AGENT_NAME,
PARENT_ID,
] as const),
_source: false,
},
}
);
transactionResponse.hits.hits.forEach((hit) => {
const spanId = String(hit.fields[PARENT_ID]![0]);
const destination = destinationsBySpanId.get(spanId);
if (destination) {
destinationsBySpanId.set(spanId, {
...destination,
serviceName: String(hit.fields[SERVICE_NAME]![0]),
environment: String(
hit.fields[SERVICE_ENVIRONMENT]?.[0] ??
ENVIRONMENT_NOT_DEFINED.value
),
agentName: hit.fields[AGENT_NAME]![0] as AgentName,
});
}
});
const nodesByBackendName = new Map<string, Node>();
destinationsBySpanId.forEach((destination) => {
const existingDestination =
nodesByBackendName.get(destination.backendName) ?? {};
const mergedDestination = {
...existingDestination,
...destination,
};
let node: Node;
if ('serviceName' in mergedDestination) {
node = {
serviceName: mergedDestination.serviceName,
agentName: mergedDestination.agentName,
environment: mergedDestination.environment,
id: objectHash({ serviceName: mergedDestination.serviceName }),
type: NodeType.service,
};
} else {
node = {
backendName: mergedDestination.backendName,
spanType: mergedDestination.spanType,
spanSubtype: mergedDestination.spanSubtype,
id: objectHash({ backendName: mergedDestination.backendName }),
type: NodeType.backend,
};
}
nodesByBackendName.set(destination.backendName, node);
});
return nodesByBackendName;
});
};

View file

@ -0,0 +1,220 @@
/*
* 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 { sum } from 'lodash';
import objectHash from 'object-hash';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent';
import { getOffsetInMs } from '../../../../common/utils/get_offset_in_ms';
import { ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values';
import { asMutableArray } from '../../../../common/utils/as_mutable_array';
import {
AGENT_NAME,
EVENT_OUTCOME,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM,
SPAN_SUBTYPE,
SPAN_TYPE,
} from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { rangeQuery } from '../../../../../observability/server';
import { getBucketSize } from '../../helpers/get_bucket_size';
import { EventOutcome } from '../../../../common/event_outcome';
import { Setup } from '../../helpers/setup_request';
import { NodeType } from '../../../../common/connections';
import { excludeRumExitSpansQuery } from '../exclude_rum_exit_spans_query';
export const getStats = async ({
setup,
start,
end,
filter,
numBuckets,
offset,
}: {
setup: Setup;
start: number;
end: number;
filter: QueryDslQueryContainer[];
numBuckets: number;
offset?: string;
}) => {
const { apmEventClient } = setup;
const { offsetInMs, startWithOffset, endWithOffset } = getOffsetInMs({
start,
end,
offset,
});
const response = await apmEventClient.search('get_connection_stats', {
apm: {
events: [ProcessorEvent.metric],
},
body: {
track_total_hits: true,
size: 0,
query: {
bool: {
filter: [
...filter,
{
exists: {
field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
},
},
...rangeQuery(startWithOffset, endWithOffset),
...excludeRumExitSpansQuery(),
],
},
},
aggs: {
connections: {
composite: {
size: 10000,
sources: asMutableArray([
{
serviceName: {
terms: {
field: SERVICE_NAME,
},
},
},
{
backendName: {
terms: {
field: SPAN_DESTINATION_SERVICE_RESOURCE,
},
},
},
] as const),
},
aggs: {
sample: {
top_metrics: {
size: 1,
metrics: asMutableArray([
{
field: SERVICE_ENVIRONMENT,
},
{
field: AGENT_NAME,
},
{
field: SPAN_TYPE,
},
{
field: SPAN_SUBTYPE,
},
] as const),
sort: {
'@timestamp': 'desc',
},
},
},
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: getBucketSize({
start: startWithOffset,
end: endWithOffset,
numBuckets,
}).intervalString,
extended_bounds: {
min: startWithOffset,
max: endWithOffset,
},
},
aggs: {
latency_sum: {
sum: {
field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM,
},
},
count: {
sum: {
field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
},
},
[EVENT_OUTCOME]: {
terms: {
field: EVENT_OUTCOME,
},
aggs: {
count: {
sum: {
field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
},
},
},
},
},
},
},
},
},
},
});
return (
response.aggregations?.connections.buckets.map((bucket) => {
const sample = bucket.sample.top[0].metrics;
const serviceName = bucket.key.serviceName as string;
const backendName = bucket.key.backendName as string;
return {
from: {
id: objectHash({ serviceName }),
serviceName,
environment: (sample[SERVICE_ENVIRONMENT] ||
ENVIRONMENT_NOT_DEFINED.value) as string,
agentName: sample[AGENT_NAME] as AgentName,
type: NodeType.service as const,
},
to: {
id: objectHash({ backendName }),
backendName,
spanType: sample[SPAN_TYPE] as string,
spanSubtype: (sample[SPAN_SUBTYPE] || '') as string,
type: NodeType.backend as const,
},
value: {
count: sum(
bucket.timeseries.buckets.map(
(dateBucket) => dateBucket.count.value ?? 0
)
),
latency_sum: sum(
bucket.timeseries.buckets.map(
(dateBucket) => dateBucket.latency_sum.value ?? 0
)
),
error_count: sum(
bucket.timeseries.buckets.flatMap(
(dateBucket) =>
dateBucket[EVENT_OUTCOME].buckets.find(
(outcomeBucket) => outcomeBucket.key === EventOutcome.failure
)?.count.value ?? 0
)
),
},
timeseries: bucket.timeseries.buckets.map((dateBucket) => ({
x: dateBucket.key + offsetInMs,
count: dateBucket.count.value ?? 0,
latency_sum: dateBucket.latency_sum.value ?? 0,
error_count:
dateBucket[EVENT_OUTCOME].buckets.find(
(outcomeBucket) => outcomeBucket.key === EventOutcome.failure
)?.count.value ?? 0,
})),
};
}) ?? []
);
};

View file

@ -0,0 +1,153 @@
/*
* 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 { ValuesType } from 'utility-types';
import { merge } from 'lodash';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import { joinByKey } from '../../../../common/utils/join_by_key';
import { Setup } from '../../helpers/setup_request';
import { getStats } from './get_stats';
import { getDestinationMap } from './get_destination_map';
import { calculateThroughput } from '../../helpers/calculate_throughput';
import { withApmSpan } from '../../../utils/with_apm_span';
export function getConnectionStats({
setup,
start,
end,
numBuckets,
filter,
collapseBy,
offset,
}: {
setup: Setup;
start: number;
end: number;
numBuckets: number;
filter: QueryDslQueryContainer[];
collapseBy: 'upstream' | 'downstream';
offset?: string;
}) {
return withApmSpan('get_connection_stats_and_map', async () => {
const [allMetrics, destinationMap] = await Promise.all([
getStats({
setup,
start,
end,
filter,
numBuckets,
offset,
}),
getDestinationMap({
setup,
start,
end,
filter,
offset,
}),
]);
const statsWithLocationIds = allMetrics.map((statsItem) => {
const { from, timeseries, value } = statsItem;
const to = destinationMap.get(statsItem.to.backendName) ?? statsItem.to;
const location = collapseBy === 'upstream' ? from : to;
return {
location,
stats: [{ timeseries, value }],
id: location.id,
};
}, []);
const statsJoinedById = joinByKey(statsWithLocationIds, 'id', (a, b) => {
const { stats: statsA, ...itemA } = a;
const { stats: statsB, ...itemB } = b;
return merge({}, itemA, itemB, { stats: statsA.concat(statsB) });
});
const statsItems = statsJoinedById.map((item) => {
const mergedStats = item.stats.reduce<ValuesType<typeof item.stats>>(
(prev, current) => {
return {
value: {
count: prev.value.count + current.value.count,
latency_sum: prev.value.latency_sum + current.value.latency_sum,
error_count: prev.value.error_count + current.value.error_count,
},
timeseries: joinByKey(
[...prev.timeseries, ...current.timeseries],
'x',
(a, b) => ({
x: a.x,
count: a.count + b.count,
latency_sum: a.latency_sum + b.latency_sum,
error_count: a.error_count + b.error_count,
})
),
};
},
{
value: {
count: 0,
latency_sum: 0,
error_count: 0,
},
timeseries: [],
}
);
const destStats = {
latency: {
value:
mergedStats.value.count > 0
? mergedStats.value.latency_sum / mergedStats.value.count
: null,
timeseries: mergedStats.timeseries.map((point) => ({
x: point.x,
y: point.count > 0 ? point.latency_sum / point.count : null,
})),
},
throughput: {
value:
mergedStats.value.count > 0
? calculateThroughput({
start,
end,
value: mergedStats.value.count,
})
: null,
timeseries: mergedStats.timeseries.map((point) => ({
x: point.x,
y:
point.count > 0
? calculateThroughput({ start, end, value: point.count })
: null,
})),
},
errorRate: {
value:
mergedStats.value.count > 0
? (mergedStats.value.error_count ?? 0) / mergedStats.value.count
: null,
timeseries: mergedStats.timeseries.map((point) => ({
x: point.x,
y: point.count > 0 ? (point.error_count ?? 0) / point.count : null,
})),
},
};
return {
...item,
stats: destStats,
};
});
return statsItems;
});
}

View file

@ -0,0 +1,45 @@
/*
* 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 { SERVICE_NAME } from '../../../common/elasticsearch_fieldnames';
import { environmentQuery } from '../../../common/utils/environment_query';
import { getConnectionStats } from '../connections/get_connection_stats';
import { getConnectionStatsItemsWithRelativeImpact } from '../connections/get_connection_stats/get_connection_stats_items_with_relative_impact';
import { Setup } from '../helpers/setup_request';
export async function getServiceDependencies({
setup,
start,
end,
serviceName,
numBuckets,
environment,
offset,
}: {
setup: Setup;
start: number;
end: number;
serviceName: string;
numBuckets: number;
environment?: string;
offset?: string;
}) {
const statsItems = await getConnectionStats({
setup,
start,
end,
numBuckets,
filter: [
{ term: { [SERVICE_NAME]: serviceName } },
...environmentQuery(environment),
],
offset,
collapseBy: 'downstream',
});
return getConnectionStatsItemsWithRelativeImpact(statsItems);
}

View file

@ -1,213 +0,0 @@
/*
* 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 { isEqual, keyBy, mapValues } from 'lodash';
import { asMutableArray } from '../../../../common/utils/as_mutable_array';
import { pickKeys } from '../../../../common/utils/pick_keys';
import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent';
import {
AGENT_NAME,
EVENT_OUTCOME,
PARENT_ID,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
SPAN_ID,
SPAN_SUBTYPE,
SPAN_TYPE,
} from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { rangeQuery } from '../../../../../observability/server';
import { environmentQuery } from '../../../../common/utils/environment_query';
import { joinByKey } from '../../../../common/utils/join_by_key';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
import { withApmSpan } from '../../../utils/with_apm_span';
export const getDestinationMap = ({
setup,
serviceName,
environment,
}: {
setup: Setup & SetupTimeRange;
serviceName: string;
environment?: string;
}) => {
return withApmSpan('get_service_destination_map', async () => {
const { start, end, apmEventClient } = setup;
const response = await apmEventClient.search('get_exit_span_samples', {
apm: {
events: [ProcessorEvent.span],
},
body: {
size: 0,
query: {
bool: {
filter: [
{ term: { [SERVICE_NAME]: serviceName } },
{ exists: { field: SPAN_DESTINATION_SERVICE_RESOURCE } },
...rangeQuery(start, end),
...environmentQuery(environment),
],
},
},
aggs: {
connections: {
composite: {
size: 1000,
sources: asMutableArray([
{
[SPAN_DESTINATION_SERVICE_RESOURCE]: {
terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE },
},
},
// make sure we get samples for both successful
// and failed calls
{ [EVENT_OUTCOME]: { terms: { field: EVENT_OUTCOME } } },
] as const),
},
aggs: {
sample: {
top_hits: {
size: 1,
_source: [SPAN_TYPE, SPAN_SUBTYPE, SPAN_ID],
sort: [
{
'@timestamp': 'desc' as const,
},
],
},
},
},
},
},
},
});
const outgoingConnections =
response.aggregations?.connections.buckets.map((bucket) => {
const sample = bucket.sample.hits.hits[0]._source;
return {
[SPAN_DESTINATION_SERVICE_RESOURCE]: String(
bucket.key[SPAN_DESTINATION_SERVICE_RESOURCE]
),
[SPAN_ID]: sample.span.id,
[SPAN_TYPE]: sample.span.type,
[SPAN_SUBTYPE]: sample.span.subtype,
};
}) ?? [];
const transactionResponse = await apmEventClient.search(
'get_transactions_for_exit_spans',
{
apm: {
events: [ProcessorEvent.transaction],
},
body: {
query: {
bool: {
filter: [
{
terms: {
[PARENT_ID]: outgoingConnections.map(
(connection) => connection[SPAN_ID]
),
},
},
...rangeQuery(start, end),
],
},
},
size: outgoingConnections.length,
docvalue_fields: asMutableArray([
SERVICE_NAME,
SERVICE_ENVIRONMENT,
AGENT_NAME,
PARENT_ID,
] as const),
_source: false,
},
}
);
const incomingConnections = transactionResponse.hits.hits.map((hit) => ({
[SPAN_ID]: String(hit.fields[PARENT_ID]![0]),
service: {
name: String(hit.fields[SERVICE_NAME]![0]),
environment: String(hit.fields[SERVICE_ENVIRONMENT]?.[0] ?? ''),
agentName: hit.fields[AGENT_NAME]![0] as AgentName,
},
}));
// merge outgoing spans with transactions by span.id/parent.id
const joinedBySpanId = joinByKey(
[...outgoingConnections, ...incomingConnections],
SPAN_ID
);
// we could have multiple connections per address because
// of multiple event outcomes
const dedupedConnectionsByAddress = joinByKey(
joinedBySpanId,
SPAN_DESTINATION_SERVICE_RESOURCE
);
// identify a connection by either service.name, service.environment, agent.name
// OR span.destination.service.resource
const connectionsWithId = dedupedConnectionsByAddress.map((connection) => {
const id =
'service' in connection
? { service: connection.service }
: pickKeys(connection, SPAN_DESTINATION_SERVICE_RESOURCE);
return {
...connection,
id,
};
});
const dedupedConnectionsById = joinByKey(connectionsWithId, 'id');
const connectionsByAddress = keyBy(
connectionsWithId,
SPAN_DESTINATION_SERVICE_RESOURCE
);
// per span.destination.service.resource, return merged/deduped item
return mapValues(connectionsByAddress, ({ id }) => {
const connection = dedupedConnectionsById.find((dedupedConnection) =>
isEqual(id, dedupedConnection.id)
)!;
return {
id,
span: {
type: connection[SPAN_TYPE],
subtype: connection[SPAN_SUBTYPE],
destination: {
service: {
resource: connection[SPAN_DESTINATION_SERVICE_RESOURCE],
},
},
},
...('service' in connection && connection.service
? {
service: {
name: connection.service.name,
environment: connection.service.environment,
},
agent: {
name: connection.service.agentName,
},
}
: {}),
};
});
});
};

View file

@ -1,148 +0,0 @@
/*
* 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 { sum } from 'lodash';
import {
EVENT_OUTCOME,
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM,
} from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { environmentQuery } from '../../../../common/utils/environment_query';
import { rangeQuery } from '../../../../../observability/server';
import { getBucketSize } from '../../helpers/get_bucket_size';
import { EventOutcome } from '../../../../common/event_outcome';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
export const getMetrics = async ({
setup,
serviceName,
environment,
numBuckets,
}: {
setup: Setup & SetupTimeRange;
serviceName: string;
environment?: string;
numBuckets: number;
}) => {
const { start, end, apmEventClient } = setup;
const response = await apmEventClient.search(
'get_service_destination_metrics',
{
apm: {
events: [ProcessorEvent.metric],
},
body: {
track_total_hits: true,
size: 0,
query: {
bool: {
filter: [
{ term: { [SERVICE_NAME]: serviceName } },
{
exists: {
field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
},
},
...rangeQuery(start, end),
...environmentQuery(environment),
],
},
},
aggs: {
connections: {
terms: {
field: SPAN_DESTINATION_SERVICE_RESOURCE,
size: 100,
},
aggs: {
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: getBucketSize({ start, end, numBuckets })
.intervalString,
extended_bounds: {
min: start,
max: end,
},
},
aggs: {
latency_sum: {
sum: {
field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM,
},
},
count: {
sum: {
field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
},
},
[EVENT_OUTCOME]: {
terms: {
field: EVENT_OUTCOME,
},
aggs: {
count: {
sum: {
field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT,
},
},
},
},
},
},
},
},
},
},
}
);
return (
response.aggregations?.connections.buckets.map((bucket) => ({
span: {
destination: {
service: {
resource: String(bucket.key),
},
},
},
value: {
count: sum(
bucket.timeseries.buckets.map(
(dateBucket) => dateBucket.count.value ?? 0
)
),
latency_sum: sum(
bucket.timeseries.buckets.map(
(dateBucket) => dateBucket.latency_sum.value ?? 0
)
),
error_count: sum(
bucket.timeseries.buckets.flatMap(
(dateBucket) =>
dateBucket[EVENT_OUTCOME].buckets.find(
(outcomeBucket) => outcomeBucket.key === EventOutcome.failure
)?.count.value ?? 0
)
),
},
timeseries: bucket.timeseries.buckets.map((dateBucket) => ({
x: dateBucket.key,
count: dateBucket.count.value ?? 0,
latency_sum: dateBucket.latency_sum.value ?? 0,
error_count:
dateBucket[EVENT_OUTCOME].buckets.find(
(outcomeBucket) => outcomeBucket.key === EventOutcome.failure
)?.count.value ?? 0,
})),
})) ?? []
);
};

View file

@ -1,230 +0,0 @@
/*
* 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 { ValuesType } from 'utility-types';
import { merge } from 'lodash';
import { SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../../common/elasticsearch_fieldnames';
import { maybe } from '../../../../common/utils/maybe';
import { isFiniteNumber } from '../../../../common/utils/is_finite_number';
import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent';
import { joinByKey } from '../../../../common/utils/join_by_key';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
import { getMetrics } from './get_metrics';
import { getDestinationMap } from './get_destination_map';
import { calculateThroughput } from '../../helpers/calculate_throughput';
import { withApmSpan } from '../../../utils/with_apm_span';
export type ServiceDependencyItem = {
name: string;
latency: {
value: number | null;
timeseries: Array<{ x: number; y: number | null }>;
};
throughput: {
value: number | null;
timeseries: Array<{ x: number; y: number | null }>;
};
errorRate: {
value: number | null;
timeseries: Array<{ x: number; y: number | null }>;
};
impact: number;
} & (
| {
type: 'service';
serviceName: string;
agentName: AgentName;
environment?: string;
}
| { type: 'external'; spanType?: string; spanSubtype?: string }
);
export function getServiceDependencies({
setup,
serviceName,
environment,
numBuckets,
}: {
serviceName: string;
setup: Setup & SetupTimeRange;
environment?: string;
numBuckets: number;
}): Promise<ServiceDependencyItem[]> {
return withApmSpan('get_service_dependencies', async () => {
const { start, end } = setup;
const [allMetrics, destinationMap] = await Promise.all([
getMetrics({
setup,
serviceName,
environment,
numBuckets,
}),
getDestinationMap({
setup,
serviceName,
environment,
}),
]);
const metricsWithDestinationIds = allMetrics.map((metricItem) => {
const spanDestination = metricItem.span.destination.service.resource;
const destination = maybe(destinationMap[spanDestination]);
const id = destination?.id || {
[SPAN_DESTINATION_SERVICE_RESOURCE]: spanDestination,
};
return merge(
{
id,
metrics: [metricItem],
span: {
destination: {
service: {
resource: spanDestination,
},
},
},
},
destination
);
}, []);
const metricsJoinedByDestinationId = joinByKey(
metricsWithDestinationIds,
'id',
(a, b) => {
const { metrics: metricsA, ...itemA } = a;
const { metrics: metricsB, ...itemB } = b;
return merge({}, itemA, itemB, { metrics: metricsA.concat(metricsB) });
}
);
const metricsByResolvedAddress = metricsJoinedByDestinationId.map(
(item) => {
const mergedMetrics = item.metrics.reduce<
Omit<ValuesType<typeof item.metrics>, 'span'>
>(
(prev, current) => {
return {
value: {
count: prev.value.count + current.value.count,
latency_sum: prev.value.latency_sum + current.value.latency_sum,
error_count: prev.value.error_count + current.value.error_count,
},
timeseries: joinByKey(
[...prev.timeseries, ...current.timeseries],
'x',
(a, b) => ({
x: a.x,
count: a.count + b.count,
latency_sum: a.latency_sum + b.latency_sum,
error_count: a.error_count + b.error_count,
})
),
};
},
{
value: {
count: 0,
latency_sum: 0,
error_count: 0,
},
timeseries: [],
}
);
const destMetrics = {
latency: {
value:
mergedMetrics.value.count > 0
? mergedMetrics.value.latency_sum / mergedMetrics.value.count
: null,
timeseries: mergedMetrics.timeseries.map((point) => ({
x: point.x,
y: point.count > 0 ? point.latency_sum / point.count : null,
})),
},
throughput: {
value:
mergedMetrics.value.count > 0
? calculateThroughput({
start,
end,
value: mergedMetrics.value.count,
})
: null,
timeseries: mergedMetrics.timeseries.map((point) => ({
x: point.x,
y:
point.count > 0
? calculateThroughput({ start, end, value: point.count })
: null,
})),
},
errorRate: {
value:
mergedMetrics.value.count > 0
? (mergedMetrics.value.error_count ?? 0) /
mergedMetrics.value.count
: null,
timeseries: mergedMetrics.timeseries.map((point) => ({
x: point.x,
y:
point.count > 0 ? (point.error_count ?? 0) / point.count : null,
})),
},
};
if (item.service) {
return {
name: item.service.name,
type: 'service' as const,
serviceName: item.service.name,
environment: item.service.environment,
// agent.name should always be there, type returned from joinByKey is too pessimistic
agentName: item.agent!.name,
...destMetrics,
};
}
return {
name: item.span.destination.service.resource,
type: 'external' as const,
spanType: item.span.type,
spanSubtype: item.span.subtype,
...destMetrics,
};
}
);
const latencySums = metricsByResolvedAddress
.map(
(metric) => (metric.latency.value ?? 0) * (metric.throughput.value ?? 0)
)
.filter(isFiniteNumber);
const minLatencySum = Math.min(...latencySums);
const maxLatencySum = Math.max(...latencySums);
return metricsByResolvedAddress.map((metric) => {
const impact =
isFiniteNumber(metric.latency.value) &&
isFiniteNumber(metric.throughput.value)
? ((metric.latency.value * metric.throughput.value - minLatencySum) /
(maxLatencySum - minLatencySum)) *
100
: 0;
return {
...metric,
impact,
};
});
});
}

View file

@ -6,12 +6,107 @@
*/
import * as t from 'io-ts';
import { toNumberRt } from '@kbn/io-ts-utils';
import { setupRequest } from '../lib/helpers/setup_request';
import { environmentRt, kueryRt, offsetRt, rangeRt } from './default_api_types';
import { createApmServerRoute } from './create_apm_server_route';
import { createApmServerRouteRepository } from './create_apm_server_route_repository';
import { getMetadataForBackend } from '../lib/backends/get_metadata_for_backend';
import { getLatencyChartsForBackend } from '../lib/backends/get_latency_charts_for_backend';
import { getTopBackends } from '../lib/backends/get_top_backends';
import { getUpstreamServicesForBackend } from '../lib/backends/get_upstream_services_for_backend';
const topBackendsRoute = createApmServerRoute({
endpoint: 'GET /api/apm/backends/top_backends',
params: t.intersection([
t.type({
query: t.intersection([rangeRt, t.type({ numBuckets: toNumberRt })]),
}),
t.partial({
query: t.intersection([environmentRt, offsetRt]),
}),
]),
options: {
tags: ['access:apm'],
},
handler: async (resources) => {
const setup = await setupRequest(resources);
const { start, end } = setup;
const { environment, offset, numBuckets } = resources.params.query;
const opts = { setup, start, end, numBuckets, environment };
const [currentBackends, previousBackends] = await Promise.all([
getTopBackends(opts),
offset ? getTopBackends({ ...opts, offset }) : Promise.resolve([]),
]);
return {
backends: currentBackends.map((backend) => {
const { stats, ...rest } = backend;
const prev = previousBackends.find(
(item) => item.location.id === backend.location.id
);
return {
...rest,
currentStats: stats,
previousStats: prev?.stats ?? null,
};
}),
};
},
});
const upstreamServicesForBackendRoute = createApmServerRoute({
endpoint: 'GET /api/apm/backends/{backendName}/upstream_services',
params: t.intersection([
t.type({
path: t.type({
backendName: t.string,
}),
query: t.intersection([rangeRt, t.type({ numBuckets: toNumberRt })]),
}),
t.partial({
query: t.intersection([environmentRt, offsetRt]),
}),
]),
options: {
tags: ['access:apm'],
},
handler: async (resources) => {
const setup = await setupRequest(resources);
const { start, end } = setup;
const {
path: { backendName },
query: { environment, offset, numBuckets },
} = resources.params;
const opts = { backendName, setup, start, end, numBuckets, environment };
const [currentServices, previousServices] = await Promise.all([
getUpstreamServicesForBackend(opts),
offset
? getUpstreamServicesForBackend({ ...opts, offset })
: Promise.resolve([]),
]);
return {
services: currentServices.map((service) => {
const { stats, ...rest } = service;
const prev = previousServices.find(
(item) => item.location.id === service.location.id
);
return {
...rest,
currentStats: stats,
previousStats: prev?.stats ?? null,
};
}),
};
},
});
const backendMetadataRoute = createApmServerRoute({
endpoint: 'GET /api/apm/backends/{backendName}/metadata',
@ -88,5 +183,7 @@ const backendLatencyChartsRoute = createApmServerRoute({
});
export const backendsRouteRepository = createApmServerRouteRepository()
.add(topBackendsRoute)
.add(upstreamServicesForBackendRoute)
.add(backendMetadataRoute)
.add(backendLatencyChartsRoute);

View file

@ -37,6 +37,7 @@ import {
comparisonRangeRt,
environmentRt,
kueryRt,
offsetRt,
rangeRt,
} from './default_api_types';
import { offsetPreviousPeriodCoordinates } from '../../common/utils/offset_previous_period_coordinate';
@ -611,6 +612,7 @@ export const serviceDependenciesRoute = createApmServerRoute({
}),
environmentRt,
rangeRt,
offsetRt,
]),
}),
options: {
@ -620,16 +622,36 @@ export const serviceDependenciesRoute = createApmServerRoute({
const setup = await setupRequest(resources);
const { params } = resources;
const { serviceName } = params.path;
const { environment, numBuckets } = params.query;
const { environment, numBuckets, start, end, offset } = params.query;
const serviceDependencies = await getServiceDependencies({
const opts = {
setup,
start,
end,
serviceName,
environment,
setup,
numBuckets,
});
};
return { serviceDependencies };
const [currentPeriod, previousPeriod] = await Promise.all([
getServiceDependencies(opts),
...(offset ? [getServiceDependencies({ ...opts, offset })] : [[]]),
]);
return {
serviceDependencies: currentPeriod.map((item) => {
const { stats, ...rest } = item;
const previousPeriodItem = previousPeriod.find(
(prevItem) => item.location.id === prevItem.location.id
);
return {
...rest,
currentStats: stats,
previousStats: previousPeriodItem?.stats || null,
};
}),
};
},
});

View file

@ -5913,11 +5913,6 @@
"xpack.apm.serviceNodeNameMissing": " (空) ",
"xpack.apm.serviceOveriew.errorsTableOccurrences": "{occurrencesCount} occ.",
"xpack.apm.serviceOverview.dependenciesTableColumnBackend": "バックエンド",
"xpack.apm.serviceOverview.dependenciesTableColumnErrorRate": "エラー率",
"xpack.apm.serviceOverview.dependenciesTableColumnImpact": "インパクト",
"xpack.apm.serviceOverview.dependenciesTableColumnLatency": "レイテンシ (平均) ",
"xpack.apm.serviceOverview.dependenciesTableColumnThroughput": "スループット",
"xpack.apm.serviceOverview.dependenciesTableLinkText": "サービスマップを表示",
"xpack.apm.serviceOverview.dependenciesTableTitle": "依存関係",
"xpack.apm.serviceOverview.errorsTableColumnLastSeen": "前回の認識",
"xpack.apm.serviceOverview.errorsTableColumnName": "名前",

View file

@ -5949,11 +5949,6 @@
"xpack.apm.serviceNodeNameMissing": "(空)",
"xpack.apm.serviceOveriew.errorsTableOccurrences": "{occurrencesCount} 次",
"xpack.apm.serviceOverview.dependenciesTableColumnBackend": "后端",
"xpack.apm.serviceOverview.dependenciesTableColumnErrorRate": "错误率",
"xpack.apm.serviceOverview.dependenciesTableColumnImpact": "影响",
"xpack.apm.serviceOverview.dependenciesTableColumnLatency": "延迟(平均值)",
"xpack.apm.serviceOverview.dependenciesTableColumnThroughput": "吞吐量",
"xpack.apm.serviceOverview.dependenciesTableLinkText": "查看服务地图",
"xpack.apm.serviceOverview.dependenciesTableTitle": "依赖项",
"xpack.apm.serviceOverview.errorsTableColumnLastSeen": "最后看到时间",
"xpack.apm.serviceOverview.errorsTableColumnName": "名称",

View file

@ -8,9 +8,13 @@
import expect from '@kbn/expect';
import { last, omit, pick, sortBy } from 'lodash';
import { ValuesType } from 'utility-types';
import { Node, NodeType } from '../../../../../plugins/apm/common/connections';
import { createApmApiSupertest } from '../../../common/apm_api_supertest';
import { roundNumber } from '../../../utils';
import { ENVIRONMENT_ALL } from '../../../../../plugins/apm/common/environment_filter_values';
import {
ENVIRONMENT_ALL,
ENVIRONMENT_NOT_DEFINED,
} from '../../../../../plugins/apm/common/environment_filter_values';
import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi';
import archives from '../../../common/fixtures/es_archiver/archives_metadata';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
@ -24,6 +28,10 @@ export default function ApiTest({ getService }: FtrProviderContext) {
const archiveName = 'apm_8.0.0';
const { start, end } = archives[archiveName];
function getName(node: Node) {
return node.type === NodeType.service ? node.serviceName : node.backendName;
}
registry.when(
'Service overview dependencies when data is not loaded',
{ config: 'basic', archives: [] },
@ -228,16 +236,17 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns opbeans-node as a dependency', () => {
const opbeansNode = response.body.serviceDependencies.find(
(item) => item.type === 'service' && item.serviceName === 'opbeans-node'
(item) => getName(item.location) === 'opbeans-node'
);
expect(opbeansNode !== undefined).to.be(true);
const values = {
latency: roundNumber(opbeansNode?.latency.value),
throughput: roundNumber(opbeansNode?.throughput.value),
errorRate: roundNumber(opbeansNode?.errorRate.value),
...pick(opbeansNode, 'serviceName', 'type', 'agentName', 'environment', 'impact'),
latency: roundNumber(opbeansNode?.currentStats.latency.value),
throughput: roundNumber(opbeansNode?.currentStats.throughput.value),
errorRate: roundNumber(opbeansNode?.currentStats.errorRate.value),
impact: opbeansNode?.currentStats.impact,
...pick(opbeansNode?.location, 'serviceName', 'type', 'agentName', 'environment'),
};
const count = 4;
@ -246,7 +255,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(values).to.eql({
agentName: 'nodejs',
environment: '',
environment: ENVIRONMENT_NOT_DEFINED.value,
serviceName: 'opbeans-node',
type: 'service',
errorRate: roundNumber(errors / count),
@ -255,8 +264,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
impact: 100,
});
const firstValue = roundNumber(opbeansNode?.latency.timeseries[0].y);
const lastValue = roundNumber(last(opbeansNode?.latency.timeseries)?.y);
const firstValue = roundNumber(opbeansNode?.currentStats.latency.timeseries[0].y);
const lastValue = roundNumber(last(opbeansNode?.currentStats.latency.timeseries)?.y);
expect(firstValue).to.be(roundNumber(20 / 3));
expect(lastValue).to.be('1.000');
@ -264,16 +273,17 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns postgres as an external dependency', () => {
const postgres = response.body.serviceDependencies.find(
(item) => item.type === 'external' && item.name === 'postgres'
(item) => getName(item.location) === 'postgres'
);
expect(postgres !== undefined).to.be(true);
const values = {
latency: roundNumber(postgres?.latency.value),
throughput: roundNumber(postgres?.throughput.value),
errorRate: roundNumber(postgres?.errorRate.value),
...pick(postgres, 'spanType', 'spanSubtype', 'name', 'impact', 'type'),
latency: roundNumber(postgres?.currentStats.latency.value),
throughput: roundNumber(postgres?.currentStats.throughput.value),
errorRate: roundNumber(postgres?.currentStats.errorRate.value),
impact: postgres?.currentStats.impact,
...pick(postgres?.location, 'spanType', 'spanSubtype', 'backendName', 'type'),
};
const count = 1;
@ -283,8 +293,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(values).to.eql({
spanType: 'external',
spanSubtype: 'http',
name: 'postgres',
type: 'external',
backendName: 'postgres',
type: 'backend',
errorRate: roundNumber(errors / count),
latency: roundNumber(sum / count),
throughput: roundNumber(count / ((endTime - startTime) / 1000 / 60)),
@ -325,8 +335,25 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns at least one item', () => {
expect(response.body.serviceDependencies.length).to.be.greaterThan(0);
expectSnapshot(response.body.serviceDependencies.length).toMatchInline(`4`);
const { currentStats, ...firstItem } = sortBy(
response.body.serviceDependencies,
'currentStats.impact'
).reverse()[0];
expectSnapshot(firstItem.location).toMatchInline(`
Object {
"backendName": "postgresql",
"id": "d4e2a4d33829d41c096c26f8037921cfc7e566b2",
"spanSubtype": "postgresql",
"spanType": "db",
"type": "backend",
}
`);
expectSnapshot(
omit(response.body.serviceDependencies[0], [
omit(currentStats, [
'errorRate.timeseries',
'throughput.timeseries',
'latency.timeseries',
@ -340,19 +367,15 @@ export default function ApiTest({ getService }: FtrProviderContext) {
"latency": Object {
"value": 30177.8418777023,
},
"name": "postgresql",
"spanSubtype": "postgresql",
"spanType": "db",
"throughput": Object {
"value": 53.9666666666667,
},
"type": "external",
}
`);
});
it('returns the right names', () => {
const names = response.body.serviceDependencies.map((item) => item.name);
const names = response.body.serviceDependencies.map((item) => getName(item.location));
expectSnapshot(names.sort()).toMatchInline(`
Array [
"elasticsearch",
@ -365,7 +388,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns the right service names', () => {
const serviceNames = response.body.serviceDependencies
.map((item) => (item.type === 'service' ? item.serviceName : undefined))
.map((item) =>
item.location.type === NodeType.service ? getName(item.location) : undefined
)
.filter(Boolean);
expectSnapshot(serviceNames.sort()).toMatchInline(`
@ -378,8 +403,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns the right latency values', () => {
const latencyValues = sortBy(
response.body.serviceDependencies.map((item) => ({
name: item.name,
latency: item.latency.value,
name: getName(item.location),
latency: item.currentStats.latency.value,
})),
'name'
);
@ -409,8 +434,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns the right throughput values', () => {
const throughputValues = sortBy(
response.body.serviceDependencies.map((item) => ({
name: item.name,
throughput: item.throughput.value,
name: getName(item.location),
throughput: item.currentStats.throughput.value,
})),
'name'
);
@ -440,10 +465,10 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns the right impact values', () => {
const impactValues = sortBy(
response.body.serviceDependencies.map((item) => ({
name: item.name,
impact: item.impact,
latency: item.latency.value,
throughput: item.throughput.value,
name: getName(item.location),
impact: item.currentStats.impact,
latency: item.currentStats.latency.value,
throughput: item.currentStats.throughput.value,
})),
'name'
);