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({