[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:
Cauê Marcondes 2020-06-18 11:31:22 +01:00 committed by GitHub
parent c8c20e4ca8
commit 4ce91b342c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 529 additions and 218 deletions

View file

@ -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>
);

View file

@ -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 && (

View file

@ -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" />

View file

@ -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}

View file

@ -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);

View file

@ -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}
/>
</>
);
};

View file

@ -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>

View file

@ -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);

View 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,
};
}

View file

@ -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)

View file

@ -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 });
},
}));