[Actionable Observability] - Add latency alert history chart on the Alert details page for APM (#148011)

## Summary

Closes #147932 by adding a new latency chart that covers the last 30
days of alerts for a given rule.
And it adds annotations with the number of alerts for a given day
besides the time to recover.

<img width="1196" alt="Screenshot 2023-01-18 at 16 22 08"
src="https://user-images.githubusercontent.com/6838659/213211368-1416e620-e96c-4a98-9552-9397fc37ee1e.png">
This commit is contained in:
Faisal Kanout 2023-01-19 23:17:30 +01:00 committed by GitHub
parent ecc048cb40
commit ff3d413a6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 496 additions and 42 deletions

View file

@ -15,13 +15,11 @@ import { EuiIconTip } from '@elastic/eui';
import {
ALERT_DURATION,
ALERT_END,
ALERT_RULE_UUID,
ALERT_EVALUATION_THRESHOLD,
ALERT_RULE_TYPE_ID,
} from '@kbn/rule-data-utils';
import moment from 'moment';
import { getTransactionType } from '../../../../context/apm_service/apm_service_context';
import { useServiceAgentFetcher } from '../../../../context/apm_service/use_service_agent_fetcher';
import { useServiceTransactionTypesFetcher } from '../../../../context/apm_service/use_service_transaction_types_fetcher';
import { asPercent } from '../../../../../common/utils/formatters';
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
import { getDurationFormatter } from '../../../../../common/utils/formatters/duration';
@ -48,6 +46,7 @@ import {
import { getAggsTypeFromRule, isLatencyThresholdRuleType } from './helpers';
import { filterNil } from '../../../shared/charts/latency_chart';
import { errorRateI18n } from '../../../shared/charts/failed_transaction_rate_chart';
import { LatencyAlertsHistoryChart } from './latency_alerts_history_chart';
import {
AlertActiveRect,
AlertAnnotation,
@ -100,23 +99,7 @@ export function AlertDetailsAppSection({
.toISOString();
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { agentName } = useServiceAgentFetcher({
serviceName,
start,
end,
});
const transactionTypes = useServiceTransactionTypesFetcher({
serviceName,
start,
end,
});
const transactionType = getTransactionType({
transactionType: alert.fields[TRANSACTION_TYPE],
transactionTypes,
agentName,
});
const transactionType = alert.fields[TRANSACTION_TYPE];
const comparisonChartTheme = getComparisonChartTheme();
const INITIAL_STATE = {
currentPeriod: [],
@ -443,6 +426,18 @@ export function AlertDetailsAppSection({
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LatencyAlertsHistoryChart
ruleId={alert.fields[ALERT_RULE_UUID]}
serviceName={serviceName}
start={start}
end={end}
transactionType={transactionType}
latencyAggregationType={latencyAggregationType}
environment={environment}
timeZone={timeZone}
/>
</EuiFlexItem>
</ChartPointerEventContextProvider>
</EuiFlexGroup>
);

View file

@ -5,3 +5,4 @@
* 2.0.
*/
export const DEFAULT_DATE_FORMAT = 'HH:mm:ss';
export const CHART_ANNOTATION_RED_COLOR = '#BD271E';

View file

@ -0,0 +1,241 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import moment from 'moment';
import { EuiPanel, EuiSpacer } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import { EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiText } from '@elastic/eui';
import {
AnnotationDomainType,
LineAnnotation,
Position,
} from '@elastic/charts';
import { EuiIcon } from '@elastic/eui';
import { EuiBadge } from '@elastic/eui';
import { convertTo } from '@kbn/observability-plugin/public';
import { useFetchTriggeredAlertsHistory } from '../../../../hooks/use_fetch_triggered_alert_history';
import { getDurationFormatter } from '../../../../../common/utils/formatters';
import { getLatencyChartSelector } from '../../../../selectors/latency_chart_selectors';
import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types';
import { useFetcher } from '../../../../hooks/use_fetcher';
import { TimeseriesChart } from '../../../shared/charts/timeseries_chart';
import { filterNil } from '../../../shared/charts/latency_chart';
import {
getMaxY,
getResponseTimeTickFormatter,
} from '../../../shared/charts/transaction_charts/helper';
import { CHART_ANNOTATION_RED_COLOR } from './constants';
interface LatencyAlertsHistoryChartProps {
serviceName: string;
start: string;
end: string;
transactionType?: string;
latencyAggregationType: LatencyAggregationType;
environment: string;
timeZone: string;
ruleId: string;
}
export function LatencyAlertsHistoryChart({
serviceName,
start,
end,
transactionType,
latencyAggregationType,
environment,
timeZone,
ruleId,
}: LatencyAlertsHistoryChartProps) {
const { data, status } = useFetcher(
(callApmApi) => {
if (
serviceName &&
start &&
end &&
transactionType &&
latencyAggregationType
) {
return callApmApi(
`GET /internal/apm/services/{serviceName}/transactions/charts/latency`,
{
params: {
path: { serviceName },
query: {
environment,
kuery: '',
start: moment().subtract(30, 'days').toISOString(),
end: moment().toISOString(),
transactionType,
transactionName: undefined,
latencyAggregationType,
},
},
}
);
}
},
[
end,
environment,
latencyAggregationType,
serviceName,
start,
transactionType,
]
);
const memoizedData = useMemo(
() =>
getLatencyChartSelector({
latencyChart: data,
latencyAggregationType,
previousPeriodLabel: '',
}),
// It should only update when the data has changed
// eslint-disable-next-line react-hooks/exhaustive-deps
[data]
);
const { currentPeriod, previousPeriod } = memoizedData;
const timeseriesLatency = [currentPeriod, previousPeriod].filter(filterNil);
const latencyMaxY = getMaxY(timeseriesLatency);
const latencyFormatter = getDurationFormatter(latencyMaxY);
const { triggeredAlertsData } = useFetchTriggeredAlertsHistory({
features: 'apm',
ruleId,
});
return (
<EuiPanel hasBorder={true}>
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{serviceName}
{i18n.translate('xpack.apm.latencyChartHistory.chartTitle', {
defaultMessage: ' latency alerts history',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{i18n.translate('xpack.apm.latencyChartHistory.last30days', {
defaultMessage: 'Last 30 days',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="l">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiFlexItem grow={false}>
<EuiText color="danger">
<EuiTitle size="s">
<h3>{triggeredAlertsData?.totalTriggeredAlerts || '-'}</h3>
</EuiTitle>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{i18n.translate(
'xpack.apm.latencyChartHistory.alertsTriggered',
{
defaultMessage: 'Alerts triggered',
}
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiFlexItem grow={false}>
<EuiText>
<EuiTitle size="s">
<h3>
{triggeredAlertsData?.avgTimeToRecoverUS
? convertTo({
unit: 'minutes',
microseconds: triggeredAlertsData?.avgTimeToRecoverUS,
extended: true,
}).formatted
: '-'}
</h3>
</EuiTitle>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{i18n.translate(
'xpack.apm.latencyChartHistory.avgTimeToRecover',
{
defaultMessage: 'Avg time to recover',
}
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
<EuiSpacer size="s" />
<TimeseriesChart
id="latencyChart"
annotations={[
<LineAnnotation
id="annotations"
key={'annotationsAlertHistory'}
domainType={AnnotationDomainType.XDomain}
dataValues={
triggeredAlertsData?.histogramTriggeredAlerts
.filter((annotation) => annotation.doc_count > 0)
.map((annotation) => {
return {
dataValue: annotation.key,
header: String(annotation.doc_count),
details: moment(annotation.key_as_string).format(
'yyyy-MM-DD'
),
};
}) || []
}
style={{
line: {
strokeWidth: 3,
stroke: CHART_ANNOTATION_RED_COLOR,
opacity: 1,
},
}}
marker={<EuiIcon type="alert" color={CHART_ANNOTATION_RED_COLOR} />}
markerBody={(annotationData) => (
<>
<EuiBadge color={CHART_ANNOTATION_RED_COLOR}>
<EuiText size="xs" color="white">
{annotationData.header}
</EuiText>
</EuiBadge>
<EuiSpacer size="xs" />
</>
)}
markerPosition={Position.Top}
/>,
]}
height={200}
comparisonEnabled={false}
offset={''}
fetchStatus={status}
timeseries={timeseriesLatency}
yLabelFormat={getResponseTimeTickFormatter(latencyFormatter)}
timeZone={timeZone}
/>
</EuiPanel>
);
}

View file

@ -14,7 +14,7 @@ import {
import moment from 'moment';
import { EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DEFAULT_DATE_FORMAT } from '../constants';
import { CHART_ANNOTATION_RED_COLOR, DEFAULT_DATE_FORMAT } from '../constants';
export function AlertAnnotation({ alertStarted }: { alertStarted: number }) {
return (
@ -36,11 +36,11 @@ export function AlertAnnotation({ alertStarted }: { alertStarted: number }) {
style={{
line: {
strokeWidth: 3,
stroke: '#f00',
stroke: CHART_ANNOTATION_RED_COLOR,
opacity: 1,
},
}}
marker={<EuiIcon type="alert" color="red" />}
marker={<EuiIcon type="alert" color={CHART_ANNOTATION_RED_COLOR} />}
markerPosition={Position.Top}
/>
);

View file

@ -7,6 +7,7 @@
import React from 'react';
import { AnnotationDomainType, LineAnnotation } from '@elastic/charts';
import { CHART_ANNOTATION_RED_COLOR } from '../constants';
export function AlertThresholdAnnotation({
threshold,
@ -29,7 +30,7 @@ export function AlertThresholdAnnotation({
line: {
opacity: 0.5,
strokeWidth: 1,
stroke: 'red',
stroke: CHART_ANNOTATION_RED_COLOR,
},
}}
/>

View file

@ -0,0 +1,217 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AsApiContract } from '@kbn/actions-plugin/common';
import { HttpSetup } from '@kbn/core/public';
import {
ALERT_DURATION,
ALERT_RULE_UUID,
ALERT_START,
ALERT_STATUS,
ALERT_TIME_RANGE,
} from '@kbn/rule-data-utils';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
interface UseFetchTriggeredAlertsHistoryProps {
features: string;
ruleId: string;
}
interface FetchTriggeredAlertsHistory {
totalTriggeredAlerts: number;
histogramTriggeredAlerts: Array<{
key_as_string: string;
key: number;
doc_count: number;
}>;
error?: string;
avgTimeToRecoverUS: number;
}
interface TriggeredAlertsHistory {
isLoadingTriggeredAlertHistory: boolean;
errorTriggeredAlertHistory?: string;
triggeredAlertsData?: FetchTriggeredAlertsHistory;
}
export function useFetchTriggeredAlertsHistory({
features,
ruleId,
}: UseFetchTriggeredAlertsHistoryProps) {
const { http } = useKibana().services;
const [triggeredAlertsHistory, setTriggeredAlertsHistory] =
useState<TriggeredAlertsHistory>({
isLoadingTriggeredAlertHistory: true,
});
const isCancelledRef = useRef(false);
const abortCtrlRef = useRef(new AbortController());
const loadRuleAlertsAgg = useCallback(async () => {
isCancelledRef.current = false;
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
try {
if (!http) throw new Error('No http client');
if (!features) return;
const { index } = await fetchIndexNameAPI({
http,
features,
});
const {
totalTriggeredAlerts,
histogramTriggeredAlerts,
error,
avgTimeToRecoverUS,
} = await fetchTriggeredAlertsHistory({
http,
index,
ruleId,
signal: abortCtrlRef.current.signal,
});
if (error) throw error;
if (!isCancelledRef.current) {
setTriggeredAlertsHistory((oldState: TriggeredAlertsHistory) => ({
...oldState,
triggeredAlertsData: {
totalTriggeredAlerts,
histogramTriggeredAlerts,
avgTimeToRecoverUS,
},
isLoadingRuleAlertsAggs: false,
}));
}
} catch (error) {
if (!isCancelledRef.current) {
if (error.name !== 'AbortError') {
setTriggeredAlertsHistory((oldState: TriggeredAlertsHistory) => ({
...oldState,
isLoadingRuleAlertsAggs: false,
errorTriggeredAlertHistory: error,
triggeredAlertsData: undefined,
}));
}
}
}
}, [features, http, ruleId]);
useEffect(() => {
loadRuleAlertsAgg();
}, [loadRuleAlertsAgg]);
return triggeredAlertsHistory;
}
interface IndexName {
index: string;
}
export async function fetchIndexNameAPI({
http,
features,
}: {
http: HttpSetup;
features: string;
}): Promise<IndexName> {
const res = await http.get<{ index_name: string[] }>(
`${BASE_RAC_ALERTS_API_PATH}/index`,
{
query: { features },
}
);
return {
index: res.index_name[0],
};
}
export async function fetchTriggeredAlertsHistory({
http,
index,
ruleId,
signal,
}: {
http: HttpSetup;
index: string;
ruleId: string;
signal: AbortSignal;
}): Promise<FetchTriggeredAlertsHistory> {
try {
const res = await http.post<AsApiContract<any>>(
`${BASE_RAC_ALERTS_API_PATH}/find`,
{
signal,
body: JSON.stringify({
index,
size: 0,
query: {
bool: {
must: [
{
term: {
[ALERT_RULE_UUID]: ruleId,
},
},
{
range: {
[ALERT_TIME_RANGE]: {
gte: 'now-30d',
lt: 'now',
},
},
},
],
},
},
aggs: {
histogramTriggeredAlerts: {
date_histogram: {
field: ALERT_START,
fixed_interval: '1d',
extended_bounds: {
min: 'now-30d',
max: 'now',
},
},
},
avgTimeToRecoverUS: {
filter: {
term: {
[ALERT_STATUS]: 'recovered',
},
},
aggs: {
recoveryTime: {
avg: {
field: ALERT_DURATION,
},
},
},
},
},
}),
}
);
const totalTriggeredAlerts = res?.hits.total.value;
const histogramTriggeredAlerts =
res?.aggregations?.histogramTriggeredAlerts.buckets;
const avgTimeToRecoverUS =
res?.aggregations?.avgTimeToRecoverUS.recoveryTime.value;
return {
totalTriggeredAlerts,
histogramTriggeredAlerts,
avgTimeToRecoverUS,
};
} catch (error) {
console.error(error);
return {
error,
totalTriggeredAlerts: 0,
histogramTriggeredAlerts: [],
avgTimeToRecoverUS: 0,
};
}
}

View file

@ -109,7 +109,7 @@ function getUnitLabelAndConvertedValue(unitKey: DurationTimeUnit, value: number)
/**
* Converts a microseconds value into the unit defined.
*/
function convertTo({
export function convertTo({
unit,
microseconds,
defaultValue = NOT_AVAILABLE_LABEL,

View file

@ -22,7 +22,7 @@ export function useChartTheme(): PartialTheme[] {
chartMargins: {
left: 10,
right: 10,
top: 10,
top: 35,
bottom: 10,
},
background: {

View file

@ -121,3 +121,4 @@ export { ExploratoryViewContextProvider } from './components/shared/exploratory_
export { fromQuery, toQuery } from './utils/url';
export type { NavigationSection } from './services/navigation_registry';
export { convertTo } from '../common/utils/formatters/duration';

View file

@ -185,14 +185,6 @@ const bucketAggsTempsSchemas: t.Type<BucketAggsSchemas> = t.exact(
})
);
export const bucketAggsSchemas = t.intersection([
bucketAggsTempsSchemas,
t.partial({
aggs: t.union([t.record(t.string, bucketAggsTempsSchemas), t.undefined]),
aggregations: t.union([t.record(t.string, bucketAggsTempsSchemas), t.undefined]),
}),
]);
/**
* Schemas for the metrics Aggregations
*
@ -287,11 +279,22 @@ export const metricsAggsSchemas = t.exact(
}),
})
),
aggs: t.undefined,
aggregations: t.undefined,
})
);
export const bucketAggsSchemas = t.intersection([
bucketAggsTempsSchemas,
t.exact(
t.partial({
aggs: t.record(t.string, t.intersection([bucketAggsTempsSchemas, metricsAggsSchemas])),
aggregations: t.record(
t.string,
t.intersection([bucketAggsTempsSchemas, metricsAggsSchemas])
),
})
),
]);
export type PutIndexTemplateRequest = estypes.IndicesPutIndexTemplateRequest & {
body?: { composed_of?: string[] };
};

View file

@ -26,11 +26,7 @@ export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>
t.partial({
index: t.string,
query: t.object,
aggs: t.union([
t.record(t.string, bucketAggsSchemas),
t.record(t.string, metricsAggsSchemas),
t.undefined,
]),
aggs: t.record(t.string, t.intersection([metricsAggsSchemas, bucketAggsSchemas])),
sort: t.union([t.array(t.object), t.undefined]),
search_after: t.union([t.array(t.number), t.array(t.string), t.undefined]),
size: t.union([PositiveInteger, t.undefined]),
@ -49,7 +45,6 @@ export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>
// eslint-disable-next-line @typescript-eslint/naming-convention
const { query, aggs, _source, track_total_hits, size, index, sort, search_after } =
request.body;
const racContext = await context.rac;
const alertsClient = await racContext.getAlertsClient();
const alerts = await alertsClient.find({