mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[APM] Dependencies table for backend inventory/detail views (#106995)
This commit is contained in:
parent
2a9d17c317
commit
efd254047d
35 changed files with 1630 additions and 993 deletions
67
x-pack/plugins/apm/common/connections.ts
Normal file
67
x-pack/plugins/apm/common/connections.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -95,9 +95,7 @@ export function ServiceOverview() {
|
|||
{!isRumAgent && (
|
||||
<EuiFlexItem grow={7}>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<ServiceOverviewDependenciesTable
|
||||
serviceName={serviceName}
|
||||
/>
|
||||
<ServiceOverviewDependenciesTable />
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
42
x-pack/plugins/apm/server/lib/backends/get_top_backends.ts
Normal file
42
x-pack/plugins/apm/server/lib/backends/get_top_backends.ts
Normal 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)
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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[];
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
})),
|
||||
})) ?? []
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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": "名前",
|
||||
|
|
|
@ -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": "名称",
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue