From ff3d413a6ed8bf6f74c7feef9af6d64dc36308ce Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Thu, 19 Jan 2023 23:17:30 +0100 Subject: [PATCH] [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. Screenshot 2023-01-18 at 16 22 08 --- .../alert_details_app_section.tsx | 35 ++- .../alert_details_app_section/constants.ts | 1 + .../latency_alerts_history_chart.tsx | 241 ++++++++++++++++++ .../alert_annotation.tsx | 6 +- .../alert_threshold_annotation.tsx | 3 +- .../use_fetch_triggered_alert_history.ts | 217 ++++++++++++++++ .../common/utils/formatters/duration.ts | 2 +- .../public/hooks/use_chart_theme.tsx | 2 +- x-pack/plugins/observability/public/index.ts | 1 + x-pack/plugins/rule_registry/common/types.ts | 23 +- .../rule_registry/server/routes/find.ts | 7 +- 11 files changed, 496 insertions(+), 42 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_alerts_history_chart.tsx create mode 100644 x-pack/plugins/apm/public/hooks/use_fetch_triggered_alert_history.ts diff --git a/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/alert_details_app_section.tsx b/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/alert_details_app_section.tsx index 3bf792840ab7..73690e8b0a00 100644 --- a/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/alert_details_app_section.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/alert_details_app_section.tsx @@ -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({ + + + ); diff --git a/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/constants.ts b/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/constants.ts index 45fe209d8111..497b779d24e7 100644 --- a/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/constants.ts +++ b/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/constants.ts @@ -5,3 +5,4 @@ * 2.0. */ export const DEFAULT_DATE_FORMAT = 'HH:mm:ss'; +export const CHART_ANNOTATION_RED_COLOR = '#BD271E'; diff --git a/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_alerts_history_chart.tsx b/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_alerts_history_chart.tsx new file mode 100644 index 000000000000..4927782b722d --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_alerts_history_chart.tsx @@ -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 ( + + + + +

+ {serviceName} + {i18n.translate('xpack.apm.latencyChartHistory.chartTitle', { + defaultMessage: ' latency alerts history', + })} +

+
+
+ + + {i18n.translate('xpack.apm.latencyChartHistory.last30days', { + defaultMessage: 'Last 30 days', + })} + + +
+ + + + + + + + +

{triggeredAlertsData?.totalTriggeredAlerts || '-'}

+
+
+
+ + + {i18n.translate( + 'xpack.apm.latencyChartHistory.alertsTriggered', + { + defaultMessage: 'Alerts triggered', + } + )} + + +
+
+ + + + +

+ {triggeredAlertsData?.avgTimeToRecoverUS + ? convertTo({ + unit: 'minutes', + microseconds: triggeredAlertsData?.avgTimeToRecoverUS, + extended: true, + }).formatted + : '-'} +

+
+
+
+ + + {i18n.translate( + 'xpack.apm.latencyChartHistory.avgTimeToRecover', + { + defaultMessage: 'Avg time to recover', + } + )} + + +
+
+ + + 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={} + markerBody={(annotationData) => ( + <> + + + {annotationData.header} + + + + + )} + markerPosition={Position.Top} + />, + ]} + height={200} + comparisonEnabled={false} + offset={''} + fetchStatus={status} + timeseries={timeseriesLatency} + yLabelFormat={getResponseTimeTickFormatter(latencyFormatter)} + timeZone={timeZone} + /> +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_chart_components/alert_annotation.tsx b/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_chart_components/alert_annotation.tsx index 27354ea2ac3b..d4496e651243 100644 --- a/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_chart_components/alert_annotation.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_chart_components/alert_annotation.tsx @@ -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={} + marker={} markerPosition={Position.Top} /> ); diff --git a/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_chart_components/alert_threshold_annotation.tsx b/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_chart_components/alert_threshold_annotation.tsx index c1c6ddefd32f..0304d298d195 100644 --- a/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_chart_components/alert_threshold_annotation.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ui_components/alert_details_app_section/latency_chart_components/alert_threshold_annotation.tsx @@ -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, }, }} /> diff --git a/x-pack/plugins/apm/public/hooks/use_fetch_triggered_alert_history.ts b/x-pack/plugins/apm/public/hooks/use_fetch_triggered_alert_history.ts new file mode 100644 index 000000000000..4c457f135bf0 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_fetch_triggered_alert_history.ts @@ -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({ + 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 { + 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 { + try { + const res = await http.post>( + `${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, + }; + } +} diff --git a/x-pack/plugins/observability/common/utils/formatters/duration.ts b/x-pack/plugins/observability/common/utils/formatters/duration.ts index 96486079f3c4..d37b75cd1cab 100644 --- a/x-pack/plugins/observability/common/utils/formatters/duration.ts +++ b/x-pack/plugins/observability/common/utils/formatters/duration.ts @@ -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, diff --git a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx index 6b11566b6e5a..87e9e61036a7 100644 --- a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx +++ b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx @@ -22,7 +22,7 @@ export function useChartTheme(): PartialTheme[] { chartMargins: { left: 10, right: 10, - top: 10, + top: 35, bottom: 10, }, background: { diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 62f980bfc9d3..b18df5c6cd54 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -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'; diff --git a/x-pack/plugins/rule_registry/common/types.ts b/x-pack/plugins/rule_registry/common/types.ts index fd238b66c82b..3c0816763e22 100644 --- a/x-pack/plugins/rule_registry/common/types.ts +++ b/x-pack/plugins/rule_registry/common/types.ts @@ -185,14 +185,6 @@ const bucketAggsTempsSchemas: t.Type = 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[] }; }; diff --git a/x-pack/plugins/rule_registry/server/routes/find.ts b/x-pack/plugins/rule_registry/server/routes/find.ts index 5cdaad09503c..b2ba28fdda5d 100644 --- a/x-pack/plugins/rule_registry/server/routes/find.ts +++ b/x-pack/plugins/rule_registry/server/routes/find.ts @@ -26,11 +26,7 @@ export const findAlertsByQueryRoute = (router: IRouter 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 // 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({