[APM] Add error rate chart to Errors overview and detail views (#67327)
* creating error rate chart * adding error line chart * creating error rate chart * using date_histogram * reapplying prettier style * changing to theme color * dont sync tooltips * adding avg on error charts * addressing pr comments * adding possibility to disable legend toggle * removing x-axis ticks from histogram * return no percent when transaction count doesn return hits * addressing PR comments * addressing PR comments * returning null when there is no transaction count Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
c8c20e4ca8
commit
4ce91b342c
|
@ -5,9 +5,12 @@
|
|||
*/
|
||||
|
||||
import { EuiTitle } from '@elastic/eui';
|
||||
import theme from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import numeral from '@elastic/numeral';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { scaleUtc } from 'd3-scale';
|
||||
import d3 from 'd3';
|
||||
import { scaleUtc } from 'd3-scale';
|
||||
import mean from 'lodash.mean';
|
||||
import React from 'react';
|
||||
import { asRelativeDateTimeRange } from '../../../../utils/formatters';
|
||||
import { getTimezoneOffsetInMs } from '../../../shared/charts/CustomPlot/getTimezoneOffsetInMs';
|
||||
|
@ -17,7 +20,7 @@ import { EmptyMessage } from '../../../shared/EmptyMessage';
|
|||
|
||||
interface IBucket {
|
||||
key: number;
|
||||
count: number;
|
||||
count: number | undefined;
|
||||
}
|
||||
|
||||
// TODO: cleanup duplication of this in distribution/get_distribution.ts (ErrorDistributionAPIResponse) and transactions/distribution/index.ts (TransactionDistributionAPIResponse)
|
||||
|
@ -30,7 +33,7 @@ interface IDistribution {
|
|||
interface FormattedBucket {
|
||||
x0: number;
|
||||
x: number;
|
||||
y: number;
|
||||
y: number | undefined;
|
||||
}
|
||||
|
||||
export function getFormattedBuckets(
|
||||
|
@ -64,7 +67,7 @@ export function ErrorDistribution({ distribution, title }: Props) {
|
|||
distribution.bucketSize
|
||||
);
|
||||
|
||||
if (!buckets || distribution.noHits) {
|
||||
if (!buckets) {
|
||||
return (
|
||||
<EmptyMessage
|
||||
heading={i18n.translate('xpack.apm.errorGroupDetails.noErrorsLabel', {
|
||||
|
@ -74,6 +77,7 @@ export function ErrorDistribution({ distribution, title }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
const averageValue = mean(buckets.map((bucket) => bucket.y)) || 0;
|
||||
const xMin = d3.min(buckets, (d) => d.x0);
|
||||
const xMax = d3.max(buckets, (d) => d.x);
|
||||
const tickFormat = scaleUtc().domain([xMin, xMax]).tickFormat();
|
||||
|
@ -84,6 +88,7 @@ export function ErrorDistribution({ distribution, title }: Props) {
|
|||
<span>{title}</span>
|
||||
</EuiTitle>
|
||||
<Histogram
|
||||
noHits={distribution.noHits}
|
||||
tooltipHeader={tooltipHeader}
|
||||
verticalLineHover={(bucket: FormattedBucket) => bucket.x}
|
||||
xType="time-utc"
|
||||
|
@ -105,6 +110,17 @@ export function ErrorDistribution({ distribution, title }: Props) {
|
|||
values: { occCount: value },
|
||||
})
|
||||
}
|
||||
legends={[
|
||||
{
|
||||
color: theme.euiColorVis1,
|
||||
// 0a abbreviates large whole numbers with metric prefixes like: 1000 = 1k, 32000 = 32k, 1000000 = 1m
|
||||
legendValue: numeral(averageValue).format('0a'),
|
||||
title: i18n.translate('xpack.apm.errorGroupDetails.avgLabel', {
|
||||
defaultMessage: 'Avg.',
|
||||
}),
|
||||
legendClickDisabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -26,6 +26,9 @@ import { ErrorDistribution } from './Distribution';
|
|||
import { useLocation } from '../../../hooks/useLocation';
|
||||
import { useUrlParams } from '../../../hooks/useUrlParams';
|
||||
import { useTrackPageview } from '../../../../../observability/public';
|
||||
import { callApmApi } from '../../../services/rest/createCallApmApi';
|
||||
import { ErrorRateChart } from '../../shared/charts/ErrorRateChart';
|
||||
import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext';
|
||||
|
||||
const Titles = styled.div`
|
||||
margin-bottom: ${px(units.plus)};
|
||||
|
@ -61,49 +64,43 @@ export function ErrorGroupDetails() {
|
|||
const { urlParams, uiFilters } = useUrlParams();
|
||||
const { serviceName, start, end, errorGroupId } = urlParams;
|
||||
|
||||
const { data: errorGroupData } = useFetcher(
|
||||
(callApmApi) => {
|
||||
if (serviceName && start && end && errorGroupId) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/services/{serviceName}/errors/{groupId}',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
groupId: errorGroupId,
|
||||
},
|
||||
query: {
|
||||
start,
|
||||
end,
|
||||
uiFilters: JSON.stringify(uiFilters),
|
||||
},
|
||||
const { data: errorGroupData } = useFetcher(() => {
|
||||
if (serviceName && start && end && errorGroupId) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/services/{serviceName}/errors/{groupId}',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
groupId: errorGroupId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[serviceName, start, end, errorGroupId, uiFilters]
|
||||
);
|
||||
query: {
|
||||
start,
|
||||
end,
|
||||
uiFilters: JSON.stringify(uiFilters),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [serviceName, start, end, errorGroupId, uiFilters]);
|
||||
|
||||
const { data: errorDistributionData } = useFetcher(
|
||||
(callApmApi) => {
|
||||
if (serviceName && start && end && errorGroupId) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/services/{serviceName}/errors/distribution',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query: {
|
||||
start,
|
||||
end,
|
||||
groupId: errorGroupId,
|
||||
uiFilters: JSON.stringify(uiFilters),
|
||||
},
|
||||
const { data: errorDistributionData } = useFetcher(() => {
|
||||
if (serviceName && start && end && errorGroupId) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/services/{serviceName}/errors/distribution',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[serviceName, start, end, errorGroupId, uiFilters]
|
||||
);
|
||||
query: {
|
||||
start,
|
||||
end,
|
||||
groupId: errorGroupId,
|
||||
uiFilters: JSON.stringify(uiFilters),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [serviceName, start, end, errorGroupId, uiFilters]);
|
||||
|
||||
useTrackPageview({ app: 'apm', path: 'error_group_details' });
|
||||
useTrackPageview({ app: 'apm', path: 'error_group_details', delay: 15000 });
|
||||
|
@ -185,16 +182,24 @@ export function ErrorGroupDetails() {
|
|||
</EuiText>
|
||||
</Titles>
|
||||
)}
|
||||
|
||||
<ErrorDistribution
|
||||
distribution={errorDistributionData}
|
||||
title={i18n.translate(
|
||||
'xpack.apm.errorGroupDetails.occurrencesChartLabel',
|
||||
{
|
||||
defaultMessage: 'Occurrences',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<ChartsSyncContextProvider>
|
||||
<EuiFlexItem>
|
||||
<ErrorDistribution
|
||||
distribution={errorDistributionData}
|
||||
title={i18n.translate(
|
||||
'xpack.apm.errorGroupDetails.occurrencesChartLabel',
|
||||
{
|
||||
defaultMessage: 'Occurrences',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<ErrorRateChart />
|
||||
</EuiFlexItem>
|
||||
</ChartsSyncContextProvider>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
<EuiSpacer size="s" />
|
||||
{showDetails && (
|
||||
|
|
|
@ -13,64 +13,61 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useFetcher } from '../../../hooks/useFetcher';
|
||||
import { ErrorDistribution } from '../ErrorGroupDetails/Distribution';
|
||||
import { ErrorGroupList } from './List';
|
||||
import { useUrlParams } from '../../../hooks/useUrlParams';
|
||||
import { useTrackPageview } from '../../../../../observability/public';
|
||||
import { PROJECTION } from '../../../../common/projections/typings';
|
||||
import { useFetcher } from '../../../hooks/useFetcher';
|
||||
import { useUrlParams } from '../../../hooks/useUrlParams';
|
||||
import { callApmApi } from '../../../services/rest/createCallApmApi';
|
||||
import { ErrorRateChart } from '../../shared/charts/ErrorRateChart';
|
||||
import { LocalUIFilters } from '../../shared/LocalUIFilters';
|
||||
import { ErrorDistribution } from '../ErrorGroupDetails/Distribution';
|
||||
import { ErrorGroupList } from './List';
|
||||
import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext';
|
||||
|
||||
const ErrorGroupOverview: React.FC = () => {
|
||||
const { urlParams, uiFilters } = useUrlParams();
|
||||
|
||||
const { serviceName, start, end, sortField, sortDirection } = urlParams;
|
||||
|
||||
const { data: errorDistributionData } = useFetcher(
|
||||
(callApmApi) => {
|
||||
if (serviceName && start && end) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/services/{serviceName}/errors/distribution',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query: {
|
||||
start,
|
||||
end,
|
||||
uiFilters: JSON.stringify(uiFilters),
|
||||
},
|
||||
const { data: errorDistributionData } = useFetcher(() => {
|
||||
if (serviceName && start && end) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/services/{serviceName}/errors/distribution',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[serviceName, start, end, uiFilters]
|
||||
);
|
||||
|
||||
const { data: errorGroupListData } = useFetcher(
|
||||
(callApmApi) => {
|
||||
const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if (serviceName && start && end) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/services/{serviceName}/errors',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query: {
|
||||
start,
|
||||
end,
|
||||
sortField,
|
||||
sortDirection: normalizedSortDirection,
|
||||
uiFilters: JSON.stringify(uiFilters),
|
||||
},
|
||||
query: {
|
||||
start,
|
||||
end,
|
||||
uiFilters: JSON.stringify(uiFilters),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[serviceName, start, end, sortField, sortDirection, uiFilters]
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [serviceName, start, end, uiFilters]);
|
||||
|
||||
const { data: errorGroupListData } = useFetcher(() => {
|
||||
const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if (serviceName && start && end) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/services/{serviceName}/errors',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query: {
|
||||
start,
|
||||
end,
|
||||
sortField,
|
||||
sortDirection: normalizedSortDirection,
|
||||
uiFilters: JSON.stringify(uiFilters),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [serviceName, start, end, sortField, sortDirection, uiFilters]);
|
||||
|
||||
useTrackPageview({
|
||||
app: 'apm',
|
||||
|
@ -102,20 +99,27 @@ const ErrorGroupOverview: React.FC = () => {
|
|||
<LocalUIFilters {...localUIFiltersConfig} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={7}>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel>
|
||||
<ErrorDistribution
|
||||
distribution={errorDistributionData}
|
||||
title={i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle',
|
||||
{
|
||||
defaultMessage: 'Error occurrences',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<ChartsSyncContextProvider>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel>
|
||||
<ErrorDistribution
|
||||
distribution={errorDistributionData}
|
||||
title={i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle',
|
||||
{
|
||||
defaultMessage: 'Error occurrences',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel>
|
||||
<ErrorRateChart />
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</ChartsSyncContextProvider>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
|
|
|
@ -105,7 +105,9 @@ export default function Legends({
|
|||
return (
|
||||
<Legend
|
||||
key={i}
|
||||
onClick={() => clickLegend(i)}
|
||||
onClick={
|
||||
serie.legendClickDisabled ? undefined : () => clickLegend(i)
|
||||
}
|
||||
disabled={seriesEnabledState[i]}
|
||||
text={text}
|
||||
color={serie.color}
|
||||
|
|
|
@ -144,7 +144,7 @@ export class InnerCustomPlot extends PureComponent {
|
|||
const hasValidCoordinates = flatten(series.map((s) => s.data)).some((p) =>
|
||||
isValidCoordinateValue(p.y)
|
||||
);
|
||||
const noHits = !hasValidCoordinates;
|
||||
const noHits = this.props.noHits || !hasValidCoordinates;
|
||||
|
||||
const plotValues = this.getPlotValues({
|
||||
visibleSeries,
|
||||
|
@ -234,6 +234,7 @@ InnerCustomPlot.propTypes = {
|
|||
firstSeen: PropTypes.number,
|
||||
})
|
||||
),
|
||||
noHits: PropTypes.bool,
|
||||
};
|
||||
|
||||
InnerCustomPlot.defaultProps = {
|
||||
|
@ -241,6 +242,8 @@ InnerCustomPlot.defaultProps = {
|
|||
tickFormatX: undefined,
|
||||
tickFormatY: (y) => y,
|
||||
truncateLegends: false,
|
||||
xAxisTickSizeOuter: 0,
|
||||
noHits: false,
|
||||
};
|
||||
|
||||
export default makeWidthFlexible(InnerCustomPlot);
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { EuiTitle } from '@elastic/eui';
|
||||
import theme from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import mean from 'lodash.mean';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useChartsSync } from '../../../../hooks/useChartsSync';
|
||||
import { useFetcher } from '../../../../hooks/useFetcher';
|
||||
import { useUrlParams } from '../../../../hooks/useUrlParams';
|
||||
import { callApmApi } from '../../../../services/rest/createCallApmApi';
|
||||
import { unit } from '../../../../style/variables';
|
||||
import { asPercent } from '../../../../utils/formatters';
|
||||
// @ts-ignore
|
||||
import CustomPlot from '../CustomPlot';
|
||||
|
||||
const tickFormatY = (y?: number) => {
|
||||
return asPercent(y || 0, 1);
|
||||
};
|
||||
|
||||
export const ErrorRateChart = () => {
|
||||
const { urlParams, uiFilters } = useUrlParams();
|
||||
const syncedChartsProps = useChartsSync();
|
||||
|
||||
const { serviceName, start, end, errorGroupId } = urlParams;
|
||||
const { data: errorRateData } = useFetcher(() => {
|
||||
if (serviceName && start && end) {
|
||||
return callApmApi({
|
||||
pathname: '/api/apm/services/{serviceName}/errors/rate',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query: {
|
||||
start,
|
||||
end,
|
||||
uiFilters: JSON.stringify(uiFilters),
|
||||
groupId: errorGroupId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [serviceName, start, end, uiFilters, errorGroupId]);
|
||||
|
||||
const combinedOnHover = useCallback(
|
||||
(hoverX: number) => {
|
||||
return syncedChartsProps.onHover(hoverX);
|
||||
},
|
||||
[syncedChartsProps]
|
||||
);
|
||||
|
||||
const errorRates = errorRateData?.errorRates || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiTitle size="xs">
|
||||
<span>
|
||||
{i18n.translate('xpack.apm.errorRateChart.title', {
|
||||
defaultMessage: 'Error Rate',
|
||||
})}
|
||||
</span>
|
||||
</EuiTitle>
|
||||
<CustomPlot
|
||||
{...syncedChartsProps}
|
||||
noHits={errorRateData?.noHits}
|
||||
series={[
|
||||
{
|
||||
color: theme.euiColorVis7,
|
||||
data: [],
|
||||
legendValue: tickFormatY(mean(errorRates.map((rate) => rate.y))),
|
||||
legendClickDisabled: true,
|
||||
title: i18n.translate('xpack.apm.errorRateChart.avgLabel', {
|
||||
defaultMessage: 'Avg.',
|
||||
}),
|
||||
type: 'linemark',
|
||||
hideTooltipValue: true,
|
||||
},
|
||||
{
|
||||
data: errorRates,
|
||||
type: 'line',
|
||||
color: theme.euiColorVis7,
|
||||
hideLegend: true,
|
||||
title: i18n.translate('xpack.apm.errorRateChart.rateLabel', {
|
||||
defaultMessage: 'Rate',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
onHover={combinedOnHover}
|
||||
tickFormatY={tickFormatY}
|
||||
formatTooltipValue={({ y }: { y?: number }) =>
|
||||
Number.isFinite(y) ? tickFormatY(y) : 'N/A'
|
||||
}
|
||||
height={unit * 10}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -114,7 +114,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
x1={0}
|
||||
x2={0}
|
||||
y1={-0}
|
||||
y2={10}
|
||||
y2={0}
|
||||
/>
|
||||
<text
|
||||
className="rv-xy-plot__axis__tick__text"
|
||||
|
@ -125,7 +125,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
}
|
||||
}
|
||||
textAnchor="middle"
|
||||
transform="translate(0, 18)"
|
||||
transform="translate(0, 8)"
|
||||
>
|
||||
0 ms
|
||||
</text>
|
||||
|
@ -149,7 +149,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
x1={0}
|
||||
x2={0}
|
||||
y1={-0}
|
||||
y2={10}
|
||||
y2={0}
|
||||
/>
|
||||
<text
|
||||
className="rv-xy-plot__axis__tick__text"
|
||||
|
@ -160,7 +160,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
}
|
||||
}
|
||||
textAnchor="middle"
|
||||
transform="translate(0, 18)"
|
||||
transform="translate(0, 8)"
|
||||
>
|
||||
500 ms
|
||||
</text>
|
||||
|
@ -184,7 +184,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
x1={0}
|
||||
x2={0}
|
||||
y1={-0}
|
||||
y2={10}
|
||||
y2={0}
|
||||
/>
|
||||
<text
|
||||
className="rv-xy-plot__axis__tick__text"
|
||||
|
@ -195,7 +195,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
}
|
||||
}
|
||||
textAnchor="middle"
|
||||
transform="translate(0, 18)"
|
||||
transform="translate(0, 8)"
|
||||
>
|
||||
1,000 ms
|
||||
</text>
|
||||
|
@ -219,7 +219,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
x1={0}
|
||||
x2={0}
|
||||
y1={-0}
|
||||
y2={10}
|
||||
y2={0}
|
||||
/>
|
||||
<text
|
||||
className="rv-xy-plot__axis__tick__text"
|
||||
|
@ -230,7 +230,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
}
|
||||
}
|
||||
textAnchor="middle"
|
||||
transform="translate(0, 18)"
|
||||
transform="translate(0, 8)"
|
||||
>
|
||||
1,500 ms
|
||||
</text>
|
||||
|
@ -254,7 +254,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
x1={0}
|
||||
x2={0}
|
||||
y1={-0}
|
||||
y2={10}
|
||||
y2={0}
|
||||
/>
|
||||
<text
|
||||
className="rv-xy-plot__axis__tick__text"
|
||||
|
@ -265,7 +265,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
}
|
||||
}
|
||||
textAnchor="middle"
|
||||
transform="translate(0, 18)"
|
||||
transform="translate(0, 8)"
|
||||
>
|
||||
2,000 ms
|
||||
</text>
|
||||
|
@ -289,7 +289,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
x1={0}
|
||||
x2={0}
|
||||
y1={-0}
|
||||
y2={10}
|
||||
y2={0}
|
||||
/>
|
||||
<text
|
||||
className="rv-xy-plot__axis__tick__text"
|
||||
|
@ -300,7 +300,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
}
|
||||
}
|
||||
textAnchor="middle"
|
||||
transform="translate(0, 18)"
|
||||
transform="translate(0, 8)"
|
||||
>
|
||||
2,500 ms
|
||||
</text>
|
||||
|
@ -324,7 +324,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
x1={0}
|
||||
x2={0}
|
||||
y1={-0}
|
||||
y2={10}
|
||||
y2={0}
|
||||
/>
|
||||
<text
|
||||
className="rv-xy-plot__axis__tick__text"
|
||||
|
@ -335,7 +335,7 @@ exports[`Histogram Initially should have default markup 1`] = `
|
|||
}
|
||||
}
|
||||
textAnchor="middle"
|
||||
transform="translate(0, 18)"
|
||||
transform="translate(0, 8)"
|
||||
>
|
||||
3,000 ms
|
||||
</text>
|
||||
|
|
|
@ -26,6 +26,10 @@ import Tooltip from '../Tooltip';
|
|||
import theme from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import { tint } from 'polished';
|
||||
import { getTimeTicksTZ, getDomainTZ } from '../helper/timezone';
|
||||
import Legends from '../CustomPlot/Legends';
|
||||
import StatusText from '../CustomPlot/StatusText';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue';
|
||||
|
||||
const XY_HEIGHT = unit * 10;
|
||||
const XY_MARGIN = {
|
||||
|
@ -99,6 +103,7 @@ export class HistogramInner extends PureComponent {
|
|||
tooltipHeader,
|
||||
verticalLineHover,
|
||||
width: XY_WIDTH,
|
||||
legends,
|
||||
} = this.props;
|
||||
const { hoveredBucket } = this.state;
|
||||
if (isEmpty(buckets) || XY_WIDTH === 0) {
|
||||
|
@ -139,102 +144,140 @@ export class HistogramInner extends PureComponent {
|
|||
const showVerticalLineHover = verticalLineHover(hoveredBucket);
|
||||
const showBackgroundHover = backgroundHover(hoveredBucket);
|
||||
|
||||
const hasValidCoordinates = buckets.some((bucket) =>
|
||||
isValidCoordinateValue(bucket.y)
|
||||
);
|
||||
const noHits = this.props.noHits || !hasValidCoordinates;
|
||||
|
||||
const xyPlotProps = {
|
||||
dontCheckIfEmpty: true,
|
||||
xType: this.props.xType,
|
||||
width: XY_WIDTH,
|
||||
height: XY_HEIGHT,
|
||||
margin: XY_MARGIN,
|
||||
xDomain: xDomain,
|
||||
yDomain: yDomain,
|
||||
};
|
||||
|
||||
const xAxisProps = {
|
||||
style: { strokeWidth: '1px' },
|
||||
marginRight: 10,
|
||||
tickSize: 0,
|
||||
tickTotal: X_TICK_TOTAL,
|
||||
tickFormat: formatX,
|
||||
tickValues: xTickValues,
|
||||
};
|
||||
|
||||
const emptyStateChart = (
|
||||
<XYPlot {...xyPlotProps}>
|
||||
<XAxis {...xAxisProps} />
|
||||
<StatusText
|
||||
marginLeft={30}
|
||||
text={i18n.translate('xpack.apm.histogram.plot.noDataLabel', {
|
||||
defaultMessage: 'No data within this time range.',
|
||||
})}
|
||||
/>
|
||||
</XYPlot>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: XY_HEIGHT }}>
|
||||
<ChartsWrapper>
|
||||
<XYPlot
|
||||
xType={this.props.xType}
|
||||
width={XY_WIDTH}
|
||||
height={XY_HEIGHT}
|
||||
margin={XY_MARGIN}
|
||||
xDomain={xDomain}
|
||||
yDomain={yDomain}
|
||||
>
|
||||
<HorizontalGridLines tickValues={yTickValues} />
|
||||
<XAxis
|
||||
style={{ strokeWidth: '1px' }}
|
||||
marginRight={10}
|
||||
tickSizeOuter={10}
|
||||
tickSizeInner={0}
|
||||
tickTotal={X_TICK_TOTAL}
|
||||
tickFormat={formatX}
|
||||
tickValues={xTickValues}
|
||||
/>
|
||||
<YAxis
|
||||
tickSize={0}
|
||||
hideLine
|
||||
tickValues={yTickValues}
|
||||
tickFormat={formatYShort}
|
||||
/>
|
||||
{noHits ? (
|
||||
<>{emptyStateChart}</>
|
||||
) : (
|
||||
<>
|
||||
<XYPlot {...xyPlotProps}>
|
||||
<HorizontalGridLines tickValues={yTickValues} />
|
||||
<XAxis {...xAxisProps} />
|
||||
<YAxis
|
||||
tickSize={0}
|
||||
hideLine
|
||||
tickValues={yTickValues}
|
||||
tickFormat={formatYShort}
|
||||
/>
|
||||
|
||||
{showBackgroundHover && (
|
||||
<SingleRect
|
||||
x={x(hoveredBucket.x0)}
|
||||
width={x(bucketSize) - x(0)}
|
||||
style={{
|
||||
fill: theme.euiColorLightestShade,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showBackgroundHover && (
|
||||
<SingleRect
|
||||
x={x(hoveredBucket.x0)}
|
||||
width={x(bucketSize) - x(0)}
|
||||
style={{
|
||||
fill: theme.euiColorLightestShade,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldShowTooltip && (
|
||||
<Tooltip
|
||||
style={{
|
||||
marginLeft: '1%',
|
||||
marginRight: '1%',
|
||||
}}
|
||||
header={tooltipHeader(hoveredBucket)}
|
||||
footer={tooltipFooter(hoveredBucket)}
|
||||
tooltipPoints={[{ value: formatYLong(hoveredBucket.y) }]}
|
||||
x={hoveredBucket.xCenter}
|
||||
y={yDomain[1] / 2}
|
||||
/>
|
||||
)}
|
||||
{shouldShowTooltip && (
|
||||
<Tooltip
|
||||
style={{
|
||||
marginLeft: '1%',
|
||||
marginRight: '1%',
|
||||
}}
|
||||
header={tooltipHeader(hoveredBucket)}
|
||||
footer={tooltipFooter(hoveredBucket)}
|
||||
tooltipPoints={[{ value: formatYLong(hoveredBucket.y) }]}
|
||||
x={hoveredBucket.xCenter}
|
||||
y={yDomain[1] / 2}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedBucket && (
|
||||
<SingleRect
|
||||
x={x(selectedBucket.x0)}
|
||||
width={x(bucketSize) - x(0)}
|
||||
style={{
|
||||
fill: 'transparent',
|
||||
stroke: theme.euiColorVis1,
|
||||
rx: '0px',
|
||||
ry: '0px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedBucket && (
|
||||
<SingleRect
|
||||
x={x(selectedBucket.x0)}
|
||||
width={x(bucketSize) - x(0)}
|
||||
style={{
|
||||
fill: 'transparent',
|
||||
stroke: theme.euiColorVis1,
|
||||
rx: '0px',
|
||||
ry: '0px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VerticalRectSeries
|
||||
colorType="literal"
|
||||
data={chartData}
|
||||
style={{
|
||||
rx: '0px',
|
||||
ry: '0px',
|
||||
}}
|
||||
/>
|
||||
<VerticalRectSeries
|
||||
colorType="literal"
|
||||
data={chartData}
|
||||
style={{
|
||||
rx: '0px',
|
||||
ry: '0px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{showVerticalLineHover && (
|
||||
<VerticalGridLines tickValues={[hoveredBucket.x]} />
|
||||
)}
|
||||
{showVerticalLineHover && hoveredBucket?.x && (
|
||||
<VerticalGridLines tickValues={[hoveredBucket.x]} />
|
||||
)}
|
||||
|
||||
<Voronoi
|
||||
extent={[
|
||||
[XY_MARGIN.left, XY_MARGIN.top],
|
||||
[XY_WIDTH, XY_HEIGHT],
|
||||
]}
|
||||
nodes={buckets.map((bucket) => {
|
||||
return {
|
||||
...bucket,
|
||||
xCenter: (bucket.x0 + bucket.x) / 2,
|
||||
};
|
||||
})}
|
||||
onClick={this.onClick}
|
||||
onHover={this.onHover}
|
||||
onBlur={this.onBlur}
|
||||
x={(d) => x(d.xCenter)}
|
||||
y={() => 1}
|
||||
/>
|
||||
</XYPlot>
|
||||
<Voronoi
|
||||
extent={[
|
||||
[XY_MARGIN.left, XY_MARGIN.top],
|
||||
[XY_WIDTH, XY_HEIGHT],
|
||||
]}
|
||||
nodes={buckets.map((bucket) => {
|
||||
return {
|
||||
...bucket,
|
||||
xCenter: (bucket.x0 + bucket.x) / 2,
|
||||
};
|
||||
})}
|
||||
onClick={this.onClick}
|
||||
onHover={this.onHover}
|
||||
onBlur={this.onBlur}
|
||||
x={(d) => x(d.xCenter)}
|
||||
y={() => 1}
|
||||
/>
|
||||
</XYPlot>
|
||||
|
||||
{legends && (
|
||||
<Legends
|
||||
series={legends}
|
||||
seriesEnabledState={[]}
|
||||
hiddenSeriesCount={0}
|
||||
clickLegend={() => {}}
|
||||
truncateLegends={false}
|
||||
noHits={noHits}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ChartsWrapper>
|
||||
</div>
|
||||
);
|
||||
|
@ -255,6 +298,8 @@ HistogramInner.propTypes = {
|
|||
verticalLineHover: PropTypes.func,
|
||||
width: PropTypes.number.isRequired,
|
||||
xType: PropTypes.string,
|
||||
legends: PropTypes.array,
|
||||
noHits: PropTypes.bool,
|
||||
};
|
||||
|
||||
HistogramInner.defaultProps = {
|
||||
|
@ -265,6 +310,7 @@ HistogramInner.defaultProps = {
|
|||
tooltipHeader: () => null,
|
||||
verticalLineHover: () => null,
|
||||
xType: 'linear',
|
||||
noHits: false,
|
||||
};
|
||||
|
||||
export default makeWidthFlexible(HistogramInner);
|
||||
|
|
109
x-pack/plugins/apm/server/lib/errors/get_error_rate.ts
Normal file
109
x-pack/plugins/apm/server/lib/errors/get_error_rate.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* 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 {
|
||||
ERROR_GROUP_ID,
|
||||
PROCESSOR_EVENT,
|
||||
SERVICE_NAME,
|
||||
} from '../../../common/elasticsearch_fieldnames';
|
||||
import { ProcessorEvent } from '../../../common/processor_event';
|
||||
import { getMetricsDateHistogramParams } from '../helpers/metrics';
|
||||
import { rangeFilter } from '../helpers/range_filter';
|
||||
import {
|
||||
Setup,
|
||||
SetupTimeRange,
|
||||
SetupUIFilters,
|
||||
} from '../helpers/setup_request';
|
||||
|
||||
export async function getErrorRate({
|
||||
serviceName,
|
||||
groupId,
|
||||
setup,
|
||||
}: {
|
||||
serviceName: string;
|
||||
groupId?: string;
|
||||
setup: Setup & SetupTimeRange & SetupUIFilters;
|
||||
}) {
|
||||
const { start, end, uiFiltersES, client, indices } = setup;
|
||||
|
||||
const filter = [
|
||||
{ term: { [SERVICE_NAME]: serviceName } },
|
||||
{ range: rangeFilter(start, end) },
|
||||
...uiFiltersES,
|
||||
];
|
||||
|
||||
const aggs = {
|
||||
response_times: {
|
||||
date_histogram: getMetricsDateHistogramParams(start, end),
|
||||
},
|
||||
};
|
||||
|
||||
const getTransactionBucketAggregation = async () => {
|
||||
const resp = await client.search({
|
||||
index: indices['apm_oss.transactionIndices'],
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...filter,
|
||||
{ term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } },
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs,
|
||||
},
|
||||
});
|
||||
return {
|
||||
totalHits: resp.hits.total.value,
|
||||
responseTimeBuckets: resp.aggregations?.response_times.buckets,
|
||||
};
|
||||
};
|
||||
const getErrorBucketAggregation = async () => {
|
||||
const groupIdFilter = groupId
|
||||
? [{ term: { [ERROR_GROUP_ID]: groupId } }]
|
||||
: [];
|
||||
const resp = await client.search({
|
||||
index: indices['apm_oss.errorIndices'],
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...filter,
|
||||
...groupIdFilter,
|
||||
{ term: { [PROCESSOR_EVENT]: ProcessorEvent.error } },
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs,
|
||||
},
|
||||
});
|
||||
return resp.aggregations?.response_times.buckets;
|
||||
};
|
||||
|
||||
const [transactions, errorResponseTimeBuckets] = await Promise.all([
|
||||
getTransactionBucketAggregation(),
|
||||
getErrorBucketAggregation(),
|
||||
]);
|
||||
|
||||
const transactionCountByTimestamp: Record<number, number> = {};
|
||||
if (transactions?.responseTimeBuckets) {
|
||||
transactions.responseTimeBuckets.forEach((bucket) => {
|
||||
transactionCountByTimestamp[bucket.key] = bucket.doc_count;
|
||||
});
|
||||
}
|
||||
|
||||
const errorRates = errorResponseTimeBuckets?.map((bucket) => {
|
||||
const { key, doc_count: errorCount } = bucket;
|
||||
const relativeRate = errorCount / transactionCountByTimestamp[key];
|
||||
return { x: key, y: relativeRate };
|
||||
});
|
||||
|
||||
return {
|
||||
noHits: transactions?.totalHits === 0,
|
||||
errorRates,
|
||||
};
|
||||
}
|
|
@ -13,6 +13,7 @@ import {
|
|||
errorDistributionRoute,
|
||||
errorGroupsRoute,
|
||||
errorsRoute,
|
||||
errorRateRoute,
|
||||
} from './errors';
|
||||
import {
|
||||
serviceAgentNameRoute,
|
||||
|
@ -81,6 +82,7 @@ const createApmApi = () => {
|
|||
.add(errorDistributionRoute)
|
||||
.add(errorGroupsRoute)
|
||||
.add(errorsRoute)
|
||||
.add(errorRateRoute)
|
||||
|
||||
// Services
|
||||
.add(serviceAgentNameRoute)
|
||||
|
|
|
@ -11,6 +11,7 @@ import { getErrorGroup } from '../lib/errors/get_error_group';
|
|||
import { getErrorGroups } from '../lib/errors/get_error_groups';
|
||||
import { setupRequest } from '../lib/helpers/setup_request';
|
||||
import { uiFiltersRt, rangeRt } from './default_api_types';
|
||||
import { getErrorRate } from '../lib/errors/get_error_rate';
|
||||
|
||||
export const errorsRoute = createRoute(() => ({
|
||||
path: '/api/apm/services/{serviceName}/errors',
|
||||
|
@ -80,3 +81,26 @@ export const errorDistributionRoute = createRoute(() => ({
|
|||
return getErrorDistribution({ serviceName, groupId, setup });
|
||||
},
|
||||
}));
|
||||
|
||||
export const errorRateRoute = createRoute(() => ({
|
||||
path: '/api/apm/services/{serviceName}/errors/rate',
|
||||
params: {
|
||||
path: t.type({
|
||||
serviceName: t.string,
|
||||
}),
|
||||
query: t.intersection([
|
||||
t.partial({
|
||||
groupId: t.string,
|
||||
}),
|
||||
uiFiltersRt,
|
||||
rangeRt,
|
||||
]),
|
||||
},
|
||||
handler: async ({ context, request }) => {
|
||||
const setup = await setupRequest(context, request);
|
||||
const { params } = context;
|
||||
const { serviceName } = params.path;
|
||||
const { groupId } = params.query;
|
||||
return getErrorRate({ serviceName, groupId, setup });
|
||||
},
|
||||
}));
|
||||
|
|
Loading…
Reference in a new issue