[APM] Service overview instances table (#85770) (#85931)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dario Gieselaar 2020-12-15 19:43:15 +01:00 committed by GitHub
parent 360a304358
commit 7d7ebb15bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1055 additions and 249 deletions

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label';
import { SparkPlot } from '../../../shared/charts/spark_plot';
export function ServiceListMetric({
color,
@ -15,11 +15,5 @@ export function ServiceListMetric({
series?: Array<{ x: number; y: number | null }>;
valueLabel: React.ReactNode;
}) {
return (
<SparkPlotWithValueLabel
valueLabel={valueLabel}
series={series}
color={color}
/>
);
return <SparkPlot valueLabel={valueLabel} series={series} color={color} />;
}

View file

@ -4,14 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiFlexGroup,
EuiFlexItem,
EuiPage,
EuiPanel,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel } from '@elastic/eui';
import React from 'react';
import { useTrackPageview } from '../../../../../observability/public';
import { isRumAgentName } from '../../../../common/agent_name';
@ -23,6 +16,7 @@ import { TransactionErrorRateChart } from '../../shared/charts/transaction_error
import { SearchBar } from '../../shared/search_bar';
import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table';
import { ServiceOverviewErrorsTable } from './service_overview_errors_table';
import { ServiceOverviewInstancesTable } from './service_overview_instances_table';
import { ServiceOverviewThroughputChart } from './service_overview_throughput_chart';
import { ServiceOverviewTransactionsTable } from './service_overview_transactions_table';
@ -101,36 +95,9 @@ export function ServiceOverview({
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={4}>
<EuiPanel>
<EuiTitle size="xs">
<h2>
{i18n.translate(
'xpack.apm.serviceOverview.instancesLatencyDistributionChartTitle',
{
defaultMessage: 'Instances latency distribution',
}
)}
</h2>
</EuiTitle>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={6}>
<EuiPanel>
<EuiTitle size="xs">
<h2>
{i18n.translate(
'xpack.apm.serviceOverview.instancesTableTitle',
{
defaultMessage: 'Instances',
}
)}
</h2>
</EuiTitle>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiPanel>
<ServiceOverviewInstancesTable serviceName={serviceName} />
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPage>

View file

@ -27,12 +27,12 @@ import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
import { TableLinkFlexItem } from '../table_link_flex_item';
import { AgentIcon } from '../../../shared/AgentIcon';
import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper';
import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label';
import { SparkPlot } from '../../../shared/charts/spark_plot';
import { px, unit } from '../../../../style/variables';
import { ImpactBar } from '../../../shared/ImpactBar';
import { ServiceOverviewLink } from '../../../shared/Links/apm/service_overview_link';
import { SpanIcon } from '../../../shared/span_icon';
import { ServiceOverviewTableContainer } from '../service_overview_table';
import { ServiceOverviewTableContainer } from '../service_overview_table_container';
interface Props {
serviceName: string;
@ -88,7 +88,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) {
width: px(unit * 10),
render: (_, { latency }) => {
return (
<SparkPlotWithValueLabel
<SparkPlot
color="euiColorVis1"
series={latency.timeseries}
valueLabel={asDuration(latency.value)}
@ -108,7 +108,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) {
width: px(unit * 10),
render: (_, { throughput }) => {
return (
<SparkPlotWithValueLabel
<SparkPlot
compact
color="euiColorVis0"
series={throughput.timeseries}
@ -129,7 +129,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) {
width: px(unit * 10),
render: (_, { errorRate }) => {
return (
<SparkPlotWithValueLabel
<SparkPlot
compact
color="euiColorVis7"
series={errorRate.timeseries}

View file

@ -13,17 +13,18 @@ import {
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import styled from 'styled-components';
import { EuiBasicTable } from '@elastic/eui';
import { asInteger } from '../../../../../common/utils/formatters';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
import { callApmApi } from '../../../../services/rest/createCallApmApi';
import { px, truncate, unit } from '../../../../style/variables';
import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label';
import { SparkPlot } from '../../../shared/charts/spark_plot';
import { ErrorDetailLink } from '../../../shared/Links/apm/ErrorDetailLink';
import { ErrorOverviewLink } from '../../../shared/Links/apm/ErrorOverviewLink';
import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper';
import { TimestampTooltip } from '../../../shared/TimestampTooltip';
import { ServiceOverviewTable } from '../service_overview_table';
import { ServiceOverviewTableContainer } from '../service_overview_table_container';
import { TableLinkFlexItem } from '../table_link_flex_item';
interface Props {
@ -123,7 +124,7 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) {
width: px(unit * 12),
render: (_, { occurrences }) => {
return (
<SparkPlotWithValueLabel
<SparkPlot
color="euiColorVis7"
series={occurrences.timeseries ?? undefined}
valueLabel={i18n.translate(
@ -224,41 +225,47 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) {
</EuiFlexItem>
<EuiFlexItem>
<TableFetchWrapper status={status}>
<ServiceOverviewTable
columns={columns}
items={items}
pagination={{
pageIndex,
pageSize: PAGE_SIZE,
totalItemCount,
pageSizeOptions: [PAGE_SIZE],
hidePerPageOptions: true,
}}
loading={status === FETCH_STATUS.LOADING}
onChange={(newTableOptions: {
page?: {
index: number;
};
sort?: { field: string; direction: SortDirection };
}) => {
setTableOptions({
pageIndex: newTableOptions.page?.index ?? 0,
sort: newTableOptions.sort
? {
field: newTableOptions.sort.field as SortField,
direction: newTableOptions.sort.direction,
}
: DEFAULT_SORT,
});
}}
sorting={{
enableAllColumns: true,
sort: {
direction: sort.direction,
field: sort.field,
},
}}
/>
<ServiceOverviewTableContainer
isEmptyAndLoading={
items.length === 0 && status === FETCH_STATUS.LOADING
}
>
<EuiBasicTable
columns={columns}
items={items}
pagination={{
pageIndex,
pageSize: PAGE_SIZE,
totalItemCount,
pageSizeOptions: [PAGE_SIZE],
hidePerPageOptions: true,
}}
loading={status === FETCH_STATUS.LOADING}
onChange={(newTableOptions: {
page?: {
index: number;
};
sort?: { field: string; direction: SortDirection };
}) => {
setTableOptions({
pageIndex: newTableOptions.page?.index ?? 0,
sort: newTableOptions.sort
? {
field: newTableOptions.sort.field as SortField,
direction: newTableOptions.sort.direction,
}
: DEFAULT_SORT,
});
}}
sorting={{
enableAllColumns: true,
sort: {
direction: sort.direction,
field: sort.field,
},
}}
/>
</ServiceOverviewTableContainer>
</TableFetchWrapper>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,276 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexItem } from '@elastic/eui';
import { EuiInMemoryTable } from '@elastic/eui';
import { EuiTitle } from '@elastic/eui';
import { EuiBasicTableColumn } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ValuesType } from 'utility-types';
import { isJavaAgentName } from '../../../../../common/agent_name';
import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n';
import { SERVICE_NODE_NAME_MISSING } from '../../../../../common/service_nodes';
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import {
asDuration,
asPercent,
asTransactionRate,
} from '../../../../../common/utils/formatters';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
import {
APIReturnType,
callApmApi,
} from '../../../../services/rest/createCallApmApi';
import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip';
import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper';
import { SparkPlot } from '../../../shared/charts/spark_plot';
import { px, unit } from '../../../../style/variables';
import { ServiceOverviewTableContainer } from '../service_overview_table_container';
import { ServiceNodeMetricOverviewLink } from '../../../shared/Links/apm/ServiceNodeMetricOverviewLink';
import { MetricOverviewLink } from '../../../shared/Links/apm/MetricOverviewLink';
type ServiceInstanceItem = ValuesType<
APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances'>
>;
interface Props {
serviceName: string;
}
export function ServiceOverviewInstancesTable({ serviceName }: Props) {
const { agentName, transactionType } = useApmServiceContext();
const {
urlParams: { start, end },
uiFilters,
} = useUrlParams();
const columns: Array<EuiBasicTableColumn<ServiceInstanceItem>> = [
{
field: 'name',
name: i18n.translate(
'xpack.apm.serviceOverview.instancesTableColumnNodeName',
{
defaultMessage: 'Node name',
}
),
render: (_, item) => {
const { serviceNodeName } = item;
const isMissingServiceNodeName =
serviceNodeName === SERVICE_NODE_NAME_MISSING;
const text = isMissingServiceNodeName
? UNIDENTIFIED_SERVICE_NODES_LABEL
: serviceNodeName;
const link = isJavaAgentName(agentName) ? (
<ServiceNodeMetricOverviewLink
serviceName={serviceName}
serviceNodeName={item.serviceNodeName}
>
{text}
</ServiceNodeMetricOverviewLink>
) : (
<MetricOverviewLink
serviceName={serviceName}
mergeQuery={(query) => ({
...query,
kuery: isMissingServiceNodeName
? `NOT (service.node.name:*)`
: `service.node.name:"${item.serviceNodeName}"`,
})}
>
{text}
</MetricOverviewLink>
);
return <TruncateWithTooltip text={text} content={link} />;
},
sortable: true,
},
{
field: 'latencyValue',
name: i18n.translate(
'xpack.apm.serviceOverview.instancesTableColumnLatency',
{
defaultMessage: 'Latency',
}
),
width: px(unit * 10),
render: (_, { latency }) => {
return (
<SparkPlot
color="euiColorVis1"
series={latency?.timeseries}
valueLabel={asDuration(latency?.value)}
/>
);
},
sortable: true,
},
{
field: 'throughputValue',
name: i18n.translate(
'xpack.apm.serviceOverview.instancesTableColumnThroughput',
{
defaultMessage: 'Traffic',
}
),
width: px(unit * 10),
render: (_, { throughput }) => {
return (
<SparkPlot
compact
color="euiColorVis0"
series={throughput?.timeseries}
valueLabel={asTransactionRate(throughput?.value)}
/>
);
},
sortable: true,
},
{
field: 'errorRateValue',
name: i18n.translate(
'xpack.apm.serviceOverview.instancesTableColumnErrorRate',
{
defaultMessage: 'Error rate',
}
),
width: px(unit * 8),
render: (_, { errorRate }) => {
return (
<SparkPlot
compact
color="euiColorVis7"
series={errorRate?.timeseries}
valueLabel={asPercent(errorRate?.value, 1)}
/>
);
},
sortable: true,
},
{
field: 'cpuUsageValue',
name: i18n.translate(
'xpack.apm.serviceOverview.instancesTableColumnCpuUsage',
{
defaultMessage: 'CPU usage (avg.)',
}
),
width: px(unit * 8),
render: (_, { cpuUsage }) => {
return (
<SparkPlot
compact
color="euiColorVis2"
series={cpuUsage?.timeseries}
valueLabel={asPercent(cpuUsage?.value, 1)}
/>
);
},
sortable: true,
},
{
field: 'memoryUsageValue',
name: i18n.translate(
'xpack.apm.serviceOverview.instancesTableColumnMemoryUsage',
{
defaultMessage: 'Memory usage (avg.)',
}
),
width: px(unit * 8),
render: (_, { memoryUsage }) => {
return (
<SparkPlot
compact
color="euiColorVis3"
series={memoryUsage?.timeseries}
valueLabel={asPercent(memoryUsage?.value, 1)}
/>
);
},
sortable: true,
},
];
const { data = [], status } = useFetcher(() => {
if (!start || !end || !transactionType) {
return;
}
return callApmApi({
endpoint:
'GET /api/apm/services/{serviceName}/service_overview_instances',
params: {
path: {
serviceName,
},
query: {
start,
end,
transactionType,
uiFilters: JSON.stringify(uiFilters),
numBuckets: 20,
},
},
});
}, [start, end, serviceName, transactionType, uiFilters]);
// need top-level sortable fields for the managed table
const items = data.map((item) => ({
...item,
latencyValue: item.latency?.value ?? 0,
throughputValue: item.throughput?.value ?? 0,
errorRateValue: item.errorRate?.value ?? 0,
cpuUsageValue: item.cpuUsage?.value ?? 0,
memoryUsageValue: item.memoryUsage?.value ?? 0,
}));
const isLoading =
status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING;
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.apm.serviceOverview.instancesTableTitle', {
defaultMessage: 'All instances',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<TableFetchWrapper status={status}>
<ServiceOverviewTableContainer
isEmptyAndLoading={items.length === 0 && isLoading}
>
<EuiInMemoryTable
columns={columns}
items={items}
allowNeutralSort={false}
loading={isLoading}
pagination={{
initialPageSize: 5,
pageSizeOptions: [5],
hidePerPageOptions: true,
}}
sorting={{
sort: {
direction: 'desc',
field: 'throughputValue',
},
}}
/>
</ServiceOverviewTableContainer>
</TableFetchWrapper>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -4,8 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiBasicTable, EuiBasicTableProps } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
/**
@ -43,14 +41,3 @@ export const ServiceOverviewTableContainer = styled.div<{
isEmptyAndLoading ? 'hidden' : 'visible'};
}
`;
export function ServiceOverviewTable<T>(props: EuiBasicTableProps<T>) {
const { items, loading } = props;
const isEmptyAndLoading = !!(items.length === 0 && loading);
return (
<ServiceOverviewTableContainer isEmptyAndLoading={isEmptyAndLoading}>
<EuiBasicTable {...props} />
</ServiceOverviewTableContainer>
);
}

View file

@ -15,6 +15,7 @@ import React, { useState } from 'react';
import styled from 'styled-components';
import { EuiToolTip } from '@elastic/eui';
import { ValuesType } from 'utility-types';
import { EuiBasicTable } from '@elastic/eui';
import { useLatencyAggregationType } from '../../../../hooks/use_latency_Aggregation_type';
import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types';
import {
@ -33,9 +34,9 @@ import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDeta
import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink';
import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper';
import { TableLinkFlexItem } from '../table_link_flex_item';
import { SparkPlotWithValueLabel } from '../../../shared/charts/spark_plot/spark_plot_with_value_label';
import { SparkPlot } from '../../../shared/charts/spark_plot';
import { ImpactBar } from '../../../shared/ImpactBar';
import { ServiceOverviewTable } from '../service_overview_table';
import { ServiceOverviewTableContainer } from '../service_overview_table_container';
type ServiceTransactionGroupItem = ValuesType<
APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/overview'>['transactionGroups']
@ -208,7 +209,7 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) {
width: px(unit * 10),
render: (_, { latency }) => {
return (
<SparkPlotWithValueLabel
<SparkPlot
color="euiColorVis1"
compact
series={latency.timeseries ?? undefined}
@ -228,7 +229,7 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) {
width: px(unit * 10),
render: (_, { throughput }) => {
return (
<SparkPlotWithValueLabel
<SparkPlot
color="euiColorVis0"
compact
series={throughput.timeseries ?? undefined}
@ -248,7 +249,7 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) {
width: px(unit * 8),
render: (_, { errorRate }) => {
return (
<SparkPlotWithValueLabel
<SparkPlot
color="euiColorVis7"
compact
series={errorRate.timeseries ?? undefined}
@ -303,41 +304,47 @@ export function ServiceOverviewTransactionsTable({ serviceName }: Props) {
<EuiFlexItem>
<EuiFlexItem>
<TableFetchWrapper status={status}>
<ServiceOverviewTable
columns={columns}
items={items}
pagination={{
pageIndex,
pageSize: PAGE_SIZE,
totalItemCount,
pageSizeOptions: [PAGE_SIZE],
hidePerPageOptions: true,
}}
loading={status === FETCH_STATUS.LOADING}
onChange={(newTableOptions: {
page?: {
index: number;
};
sort?: { field: string; direction: SortDirection };
}) => {
setTableOptions({
pageIndex: newTableOptions.page?.index ?? 0,
sort: newTableOptions.sort
? {
field: newTableOptions.sort.field as SortField,
direction: newTableOptions.sort.direction,
}
: DEFAULT_SORT,
});
}}
sorting={{
enableAllColumns: true,
sort: {
direction: sort.direction,
field: sort.field,
},
}}
/>
<ServiceOverviewTableContainer
isEmptyAndLoading={
items.length === 0 && status === FETCH_STATUS.LOADING
}
>
<EuiBasicTable
columns={columns}
items={items}
pagination={{
pageIndex,
pageSize: PAGE_SIZE,
totalItemCount,
pageSizeOptions: [PAGE_SIZE],
hidePerPageOptions: true,
}}
loading={status === FETCH_STATUS.LOADING}
onChange={(newTableOptions: {
page?: {
index: number;
};
sort?: { field: string; direction: SortDirection };
}) => {
setTableOptions({
pageIndex: newTableOptions.page?.index ?? 0,
sort: newTableOptions.sort
? {
field: newTableOptions.sort.field as SortField,
direction: newTableOptions.sort.direction,
}
: DEFAULT_SORT,
});
}}
sorting={{
enableAllColumns: true,
sort: {
direction: sort.direction,
field: sort.field,
},
}}
/>
</ServiceOverviewTableContainer>
</TableFetchWrapper>
</EuiFlexItem>
</EuiFlexItem>

View file

@ -18,6 +18,7 @@ import { APMQueryParams, fromQuery, toQuery } from '../url_helpers';
interface Props extends EuiLinkAnchorProps {
path?: string;
query?: APMQueryParams;
mergeQuery?: (query: APMQueryParams) => APMQueryParams;
children?: React.ReactNode;
}
@ -74,11 +75,14 @@ export function getAPMHref({
});
}
export function APMLink({ path = '', query, ...rest }: Props) {
export function APMLink({ path = '', query, mergeQuery, ...rest }: Props) {
const { core } = useApmPluginContext();
const { search } = useLocation();
const { basePath } = core.http;
const href = getAPMHref({ basePath, path, search, query });
const mergedQuery = mergeQuery ? mergeQuery(query ?? {}) : query;
const href = getAPMHref({ basePath, path, search, query: mergedQuery });
return <EuiLink {...rest} href={href} />;
}

View file

@ -3,6 +3,10 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { EuiIcon } from '@elastic/eui';
import {
AreaSeries,
Chart,
@ -10,62 +14,81 @@ import {
ScaleType,
Settings,
} from '@elastic/charts';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
import React from 'react';
import { merge } from 'lodash';
import { useChartTheme } from '../../../../../../observability/public';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { px } from '../../../../style/variables';
import { px, unit } from '../../../../style/variables';
import { useTheme } from '../../../../hooks/use_theme';
interface Props {
color: string;
type Color =
| 'euiColorVis0'
| 'euiColorVis1'
| 'euiColorVis2'
| 'euiColorVis3'
| 'euiColorVis4'
| 'euiColorVis5'
| 'euiColorVis6'
| 'euiColorVis7'
| 'euiColorVis8'
| 'euiColorVis9';
export function SparkPlot({
color,
series,
valueLabel,
compact,
}: {
color: Color;
series?: Array<{ x: number; y: number | null }> | null;
width: string;
}
valueLabel: React.ReactNode;
compact?: boolean;
}) {
const theme = useTheme();
const defaultChartTheme = useChartTheme();
export function SparkPlot(props: Props) {
const { series, color, width } = props;
const chartTheme = useChartTheme();
const sparkplotChartTheme = merge({}, defaultChartTheme, {
lineSeriesStyle: {
point: { opacity: 0 },
},
areaSeriesStyle: {
point: { opacity: 0 },
},
});
if (!series || series.every((point) => point.y === null)) {
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="visLine" color="subdued" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{NOT_AVAILABLE_LABEL}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
const colorValue = theme.eui[color];
return (
<Chart size={{ height: px(24), width }}>
<Settings
theme={merge({}, chartTheme, {
lineSeriesStyle: {
point: { opacity: 0 },
},
areaSeriesStyle: {
point: { opacity: 0 },
},
})}
showLegend={false}
tooltip="none"
/>
<AreaSeries
id="area"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
data={series}
color={color}
curve={CurveType.CURVE_MONOTONE_X}
/>
</Chart>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
{!series || series.every((point) => point.y === null) ? (
<EuiIcon type="visLine" color="subdued" />
) : (
<Chart
size={{
height: px(24),
width: compact ? px(unit * 3) : px(unit * 4),
}}
>
<Settings
theme={sparkplotChartTheme}
showLegend={false}
tooltip="none"
/>
<AreaSeries
id="area"
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor={'x'}
yAccessors={['y']}
data={series}
color={colorValue}
curve={CurveType.CURVE_MONOTONE_X}
/>
</Chart>
)}
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ whiteSpace: 'nowrap' }}>
{valueLabel}
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -1,54 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { px, unit } from '../../../../../style/variables';
import { useTheme } from '../../../../../hooks/use_theme';
import { SparkPlot } from '../';
type Color =
| 'euiColorVis0'
| 'euiColorVis1'
| 'euiColorVis2'
| 'euiColorVis3'
| 'euiColorVis4'
| 'euiColorVis5'
| 'euiColorVis6'
| 'euiColorVis7'
| 'euiColorVis8'
| 'euiColorVis9';
export function SparkPlotWithValueLabel({
color,
series,
valueLabel,
compact,
}: {
color: Color;
series?: Array<{ x: number; y: number | null }> | null;
valueLabel: React.ReactNode;
compact?: boolean;
}) {
const theme = useTheme();
const colorValue = theme.eui[color];
return (
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
<SparkPlot
series={series}
width={compact ? px(unit * 3) : px(unit * 4)}
color={colorValue}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ whiteSpace: 'nowrap' }}>
{valueLabel}
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,150 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AggregationOptionsByType } from '../../../../../../typings/elasticsearch';
import { rangeFilter } from '../../../../common/utils/range_filter';
import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes';
import {
METRIC_CGROUP_MEMORY_USAGE_BYTES,
METRIC_PROCESS_CPU_PERCENT,
METRIC_SYSTEM_FREE_MEMORY,
METRIC_SYSTEM_TOTAL_MEMORY,
SERVICE_NAME,
SERVICE_NODE_NAME,
} from '../../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../../common/processor_event';
import { ServiceInstanceParams } from '.';
import { getBucketSize } from '../../helpers/get_bucket_size';
import {
percentCgroupMemoryUsedScript,
percentSystemMemoryUsedScript,
} from '../../metrics/by_agent/shared/memory';
export async function getServiceInstanceSystemMetricStats({
setup,
serviceName,
size,
numBuckets,
}: ServiceInstanceParams) {
const { apmEventClient, start, end, esFilter } = setup;
const { intervalString } = getBucketSize({ start, end, numBuckets });
const systemMemoryFilter = {
bool: {
filter: [
{ exists: { field: METRIC_SYSTEM_FREE_MEMORY } },
{ exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } },
],
},
};
const cgroupMemoryFilter = {
exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES },
};
const cpuUsageFilter = { exists: { field: METRIC_PROCESS_CPU_PERCENT } };
function withTimeseries<T extends AggregationOptionsByType['avg']>(agg: T) {
return {
avg: { avg: agg },
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: {
min: start,
max: end,
},
},
aggs: {
avg: { avg: agg },
},
},
};
}
const subAggs = {
memory_usage_cgroup: {
filter: cgroupMemoryFilter,
aggs: withTimeseries({ script: percentCgroupMemoryUsedScript }),
},
memory_usage_system: {
filter: systemMemoryFilter,
aggs: withTimeseries({ script: percentSystemMemoryUsedScript }),
},
cpu_usage: {
filter: cpuUsageFilter,
aggs: withTimeseries({ field: METRIC_PROCESS_CPU_PERCENT }),
},
};
const response = await apmEventClient.search({
apm: {
events: [ProcessorEvent.metric],
},
body: {
size: 0,
query: {
bool: {
filter: [
{ range: rangeFilter(start, end) },
{ term: { [SERVICE_NAME]: serviceName } },
...esFilter,
],
should: [cgroupMemoryFilter, systemMemoryFilter, cpuUsageFilter],
minimum_should_match: 1,
},
},
aggs: {
[SERVICE_NODE_NAME]: {
terms: {
field: SERVICE_NODE_NAME,
missing: SERVICE_NODE_NAME_MISSING,
size,
},
aggs: subAggs,
},
},
},
});
return (
response.aggregations?.[SERVICE_NODE_NAME].buckets.map(
(serviceNodeBucket) => {
const hasCGroupData =
serviceNodeBucket.memory_usage_cgroup.avg.value !== null;
const memoryMetricsKey = hasCGroupData
? 'memory_usage_cgroup'
: 'memory_usage_system';
return {
serviceNodeName: String(serviceNodeBucket.key),
cpuUsage: {
value: serviceNodeBucket.cpu_usage.avg.value,
timeseries: serviceNodeBucket.cpu_usage.timeseries.buckets.map(
(dateBucket) => ({
x: dateBucket.key,
y: dateBucket.avg.value,
})
),
},
memoryUsage: {
value: serviceNodeBucket[memoryMetricsKey].avg.value,
timeseries: serviceNodeBucket[
memoryMetricsKey
].timeseries.buckets.map((dateBucket) => ({
x: dateBucket.key,
y: dateBucket.avg.value,
})),
},
};
}
) ?? []
);
}

View file

@ -0,0 +1,153 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EventOutcome } from '../../../../common/event_outcome';
import { rangeFilter } from '../../../../common/utils/range_filter';
import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes';
import {
EVENT_OUTCOME,
SERVICE_NAME,
SERVICE_NODE_NAME,
TRANSACTION_TYPE,
} from '../../../../common/elasticsearch_fieldnames';
import { ServiceInstanceParams } from '.';
import { getBucketSize } from '../../helpers/get_bucket_size';
import {
getProcessorEventForAggregatedTransactions,
getTransactionDurationFieldForAggregatedTransactions,
} from '../../helpers/aggregated_transactions';
export async function getServiceInstanceTransactionStats({
setup,
transactionType,
serviceName,
size,
searchAggregatedTransactions,
numBuckets,
}: ServiceInstanceParams) {
const { apmEventClient, start, end, esFilter } = setup;
const { intervalString } = getBucketSize({ start, end, numBuckets });
const field = getTransactionDurationFieldForAggregatedTransactions(
searchAggregatedTransactions
);
const subAggs = {
count: {
value_count: {
field,
},
},
avg_transaction_duration: {
avg: {
field,
},
},
failures: {
filter: {
term: {
[EVENT_OUTCOME]: EventOutcome.failure,
},
},
aggs: {
count: {
value_count: {
field,
},
},
},
},
};
const response = await apmEventClient.search({
apm: {
events: [
getProcessorEventForAggregatedTransactions(
searchAggregatedTransactions
),
],
},
body: {
size: 0,
query: {
bool: {
filter: [
{ range: rangeFilter(start, end) },
{ term: { [SERVICE_NAME]: serviceName } },
{ term: { [TRANSACTION_TYPE]: transactionType } },
...esFilter,
],
},
},
aggs: {
[SERVICE_NODE_NAME]: {
terms: {
field: SERVICE_NODE_NAME,
missing: SERVICE_NODE_NAME_MISSING,
size,
},
aggs: {
...subAggs,
timeseries: {
date_histogram: {
field: '@timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: {
min: start,
max: end,
},
},
aggs: {
...subAggs,
},
},
},
},
},
},
});
return (
response.aggregations?.[SERVICE_NODE_NAME].buckets.map(
(serviceNodeBucket) => {
const {
count,
avg_transaction_duration: avgTransactionDuration,
key,
failures,
timeseries,
} = serviceNodeBucket;
return {
serviceNodeName: String(key),
errorRate: {
value: failures.count.value / count.value,
timeseries: timeseries.buckets.map((dateBucket) => ({
x: dateBucket.key,
y: dateBucket.failures.count.value / dateBucket.count.value,
})),
},
throughput: {
value: count.value,
timeseries: timeseries.buckets.map((dateBucket) => ({
x: dateBucket.key,
y: dateBucket.count.value,
})),
},
latency: {
value: avgTransactionDuration.value,
timeseries: timeseries.buckets.map((dateBucket) => ({
x: dateBucket.key,
y: dateBucket.avg_transaction_duration.value,
})),
},
};
}
) ?? []
);
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { joinByKey } from '../../../../common/utils/join_by_key';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
import { getServiceInstanceSystemMetricStats } from './get_service_instance_system_metric_stats';
import { getServiceInstanceTransactionStats } from './get_service_instance_transaction_stats';
export interface ServiceInstanceParams {
setup: Setup & SetupTimeRange;
serviceName: string;
transactionType: string;
searchAggregatedTransactions: boolean;
size: number;
numBuckets: number;
}
export async function getServiceInstances(
params: Omit<ServiceInstanceParams, 'size'>
) {
const paramsForSubQueries = {
...params,
size: 50,
};
const [transactionStats, systemMetricStats] = await Promise.all([
getServiceInstanceTransactionStats(paramsForSubQueries),
getServiceInstanceSystemMetricStats(paramsForSubQueries),
]);
const stats = joinByKey(
[...transactionStats, ...systemMetricStats],
'serviceNodeName'
);
return stats;
}

View file

@ -24,6 +24,7 @@ import {
serviceErrorGroupsRoute,
serviceThroughputRoute,
serviceDependenciesRoute,
serviceInstancesRoute,
} from './services';
import {
agentConfigurationRoute,
@ -124,6 +125,7 @@ const createApmApi = () => {
.add(serviceErrorGroupsRoute)
.add(serviceThroughputRoute)
.add(serviceDependenciesRoute)
.add(serviceInstancesRoute)
// Agent configuration
.add(getSingleAgentConfigurationRoute)

View file

@ -21,6 +21,7 @@ import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'
import { getServiceDependencies } from '../lib/services/get_service_dependencies';
import { toNumberRt } from '../../common/runtime_types/to_number_rt';
import { getThroughput } from '../lib/services/get_throughput';
import { getServiceInstances } from '../lib/services/get_service_instances';
export const servicesRoute = createRoute({
endpoint: 'GET /api/apm/services',
@ -277,6 +278,38 @@ export const serviceThroughputRoute = createRoute({
},
});
export const serviceInstancesRoute = createRoute({
endpoint: 'GET /api/apm/services/{serviceName}/service_overview_instances',
params: t.type({
path: t.type({
serviceName: t.string,
}),
query: t.intersection([
t.type({ transactionType: t.string, numBuckets: toNumberRt }),
uiFiltersRt,
rangeRt,
]),
}),
options: { tags: ['access:apm'] },
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const { serviceName } = context.params.path;
const { transactionType, numBuckets } = context.params.query;
const searchAggregatedTransactions = await getSearchAggregatedTransactions(
setup
);
return getServiceInstances({
serviceName,
setup,
transactionType,
searchAggregatedTransactions,
numBuckets,
});
},
});
export const serviceDependenciesRoute = createRoute({
endpoint: 'GET /api/apm/services/{serviceName}/dependencies',
params: t.type({
@ -284,8 +317,11 @@ export const serviceDependenciesRoute = createRoute({
serviceName: t.string,
}),
query: t.intersection([
t.type({
environment: t.string,
numBuckets: toNumberRt,
}),
rangeRt,
t.type({ environment: t.string, numBuckets: toNumberRt }),
]),
}),
options: {

View file

@ -23,10 +23,10 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont
loadTestFile(require.resolve('./services/transaction_types'));
});
// TODO: we should not have a service overview.
describe('Service overview', function () {
loadTestFile(require.resolve('./service_overview/error_groups'));
loadTestFile(require.resolve('./service_overview/dependencies'));
loadTestFile(require.resolve('./service_overview/instances'));
});
describe('Settings', function () {

View file

@ -0,0 +1,214 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import url from 'url';
import { pick, sortBy } from 'lodash';
import { isFiniteNumber } from '../../../../../plugins/apm/common/utils/is_finite_number';
import { APIReturnType } from '../../../../../plugins/apm/public/services/rest/createCallApmApi';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import archives from '../../../common/archives_metadata';
export default function ApiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const archiveName = 'apm_8.0.0';
const { start, end } = archives[archiveName];
interface Response {
status: number;
body: APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances'>;
}
describe('Service overview instances', () => {
describe('when data is not loaded', () => {
it('handles the empty state', async () => {
const response: Response = await supertest.get(
url.format({
pathname: `/api/apm/services/opbeans-java/service_overview_instances`,
query: {
start,
end,
numBuckets: 20,
transactionType: 'request',
uiFilters: '{}',
},
})
);
expect(response.status).to.be(200);
expect(response.body).to.eql([]);
});
});
describe('when data is loaded', () => {
before(() => esArchiver.load(archiveName));
after(() => esArchiver.unload(archiveName));
describe('fetching java data', () => {
let response: Response;
beforeEach(async () => {
response = await supertest.get(
url.format({
pathname: `/api/apm/services/opbeans-java/service_overview_instances`,
query: {
start,
end,
numBuckets: 20,
transactionType: 'request',
uiFilters: '{}',
},
})
);
});
it('returns a service node item', () => {
expect(response.body.length).to.be.greaterThan(0);
});
it('returns statistics for each service node', () => {
const item = response.body[0];
expect(isFiniteNumber(item.cpuUsage?.value)).to.be(true);
expect(isFiniteNumber(item.memoryUsage?.value)).to.be(true);
expect(isFiniteNumber(item.errorRate?.value)).to.be(true);
expect(isFiniteNumber(item.throughput?.value)).to.be(true);
expect(isFiniteNumber(item.latency?.value)).to.be(true);
expect(item.cpuUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
expect(item.memoryUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
expect(item.errorRate?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
expect(item.throughput?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
expect(item.latency?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
});
it('returns the right data', () => {
const items = sortBy(response.body, 'serviceNodeName');
const serviceNodeNames = items.map((item) => item.serviceNodeName);
expectSnapshot(items.length).toMatchInline(`1`);
expectSnapshot(serviceNodeNames).toMatchInline(`
Array [
"02950c4c5fbb0fda1cc98c47bf4024b473a8a17629db6530d95dcee68bd54c6c",
]
`);
const item = items[0];
const values = pick(item, [
'cpuUsage.value',
'memoryUsage.value',
'errorRate.value',
'throughput.value',
'latency.value',
]);
expectSnapshot(values).toMatchInline(`
Object {
"cpuUsage": Object {
"value": 0.0120166666666667,
},
"errorRate": Object {
"value": 0.16,
},
"latency": Object {
"value": 237339.813333333,
},
"memoryUsage": Object {
"value": 0.941324615478516,
},
"throughput": Object {
"value": 75,
},
}
`);
});
});
describe('fetching non-java data', () => {
let response: Response;
beforeEach(async () => {
response = await supertest.get(
url.format({
pathname: `/api/apm/services/opbeans-ruby/service_overview_instances`,
query: {
start,
end,
numBuckets: 20,
transactionType: 'request',
uiFilters: '{}',
},
})
);
});
it('returns statistics for each service node', () => {
const item = response.body[0];
expect(isFiniteNumber(item.cpuUsage?.value)).to.be(true);
expect(isFiniteNumber(item.memoryUsage?.value)).to.be(true);
expect(isFiniteNumber(item.errorRate?.value)).to.be(true);
expect(isFiniteNumber(item.throughput?.value)).to.be(true);
expect(isFiniteNumber(item.latency?.value)).to.be(true);
expect(item.cpuUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
expect(item.memoryUsage?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
expect(item.errorRate?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
expect(item.throughput?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
expect(item.latency?.timeseries.some((point) => isFiniteNumber(point.y))).to.be(true);
});
it('returns the right data', () => {
const items = sortBy(response.body, 'serviceNodeName');
const serviceNodeNames = items.map((item) => item.serviceNodeName);
expectSnapshot(items.length).toMatchInline(`1`);
expectSnapshot(serviceNodeNames).toMatchInline(`
Array [
"_service_node_name_missing_",
]
`);
const item = items[0];
const values = pick(
item,
'cpuUsage.value',
'errorRate.value',
'throughput.value',
'latency.value'
);
expectSnapshot(values).toMatchInline(`
Object {
"cpuUsage": Object {
"value": 0.00111666666666667,
},
"errorRate": Object {
"value": 0.0373134328358209,
},
"latency": Object {
"value": 70518.9328358209,
},
"throughput": Object {
"value": 134,
},
}
`);
expectSnapshot(values);
});
});
});
});
}