[Alert details page][Alert history chart] Apply group by on alert history chart (#180188)

Closes #176718

## Summary

In this PR, I will apply the group by information on the history chart
of the following rules:
1. Log threshold

![image](c8fd5915-9909-4e7e-93f1-afb05dc93854)

2. APM Latency threshold

![image](fbe70bf4-b55c-46d0-8288-75323ac9b822)

3. SLO burn rate

![image](5f142d1f-3d18-4687-85a3-68d6781416ad)

I also update the custom threshold history chart to use instanceId for
filtering alerts instead of queries.

## 🧪 How to test
Check both the number of alerts on the annotation and also the data in
the history chart to match the data for the defined group, in the
following rules.
1. Log threshold
2. APM Latency threshold
3. SLO burn rate
This commit is contained in:
Maryam Saeidi 2024-04-11 09:47:22 +02:00 committed by GitHub
parent 30b313fefc
commit 31d08d2e80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 124 additions and 33 deletions

View file

@ -59,6 +59,7 @@ describe('useAlertsHistory', () => {
expect(result.current.isSuccess).toBeFalsy();
expect(result.current.isLoading).toBeFalsy();
});
it('returns no data when API error', async () => {
const http = {
post: jest.fn().mockImplementation(() => {
@ -151,7 +152,7 @@ describe('useAlertsHistory', () => {
expect(result.current.data.totalTriggeredAlerts).toEqual(32);
});
it('calls http post including term queries', async () => {
it('calls http post including instanceId query', async () => {
const controller = new AbortController();
const signal = controller.signal;
const mockedHttpPost = jest.fn();
@ -176,15 +177,56 @@ describe('useAlertsHistory', () => {
featureIds: [AlertConsumers.APM],
ruleId,
dateRange: { from: start, to: end },
queries: [
{
term: {
'kibana.alert.group.value': {
value: 'host=1',
},
},
},
],
instanceId: 'instance-1',
}),
{
wrapper,
}
);
await act(async () => {
await waitFor(() => result.current.isSuccess);
});
expect(mockedHttpPost).toBeCalledWith('/internal/rac/alerts/find', {
body:
'{"size":0,"feature_ids":["apm"],"query":{"bool":{"must":[' +
'{"term":{"kibana.alert.rule.uuid":"cfd36e60-ef22-11ed-91eb-b7893acacfe2"}},' +
'{"term":{"kibana.alert.instance.id":"instance-1"}},' +
'{"range":{"kibana.alert.time_range":{"from":"2023-04-10T00:00:00.000Z","to":"2023-05-10T00:00:00.000Z"}}}]}},' +
'"aggs":{"histogramTriggeredAlerts":{"date_histogram":{"field":"kibana.alert.start","fixed_interval":"1d",' +
'"extended_bounds":{"min":"2023-04-10T00:00:00.000Z","max":"2023-05-10T00:00:00.000Z"}}},' +
'"avgTimeToRecoverUS":{"filter":{"term":{"kibana.alert.status":"recovered"}},' +
'"aggs":{"recoveryTime":{"avg":{"field":"kibana.alert.duration.us"}}}}}}',
signal,
});
});
it('calls http post without * instanceId query', async () => {
const controller = new AbortController();
const signal = controller.signal;
const mockedHttpPost = jest.fn();
const http = {
post: mockedHttpPost.mockResolvedValue({
hits: { total: { value: 32, relation: 'eq' }, max_score: null, hits: [] },
aggregations: {
avgTimeToRecoverUS: { doc_count: 28, recoveryTime: { value: 134959464.2857143 } },
histogramTriggeredAlerts: {
buckets: [
{ key_as_string: '2023-04-10T00:00:00.000Z', key: 1681084800000, doc_count: 0 },
],
},
},
}),
} as unknown as HttpSetup;
const { result, waitFor } = renderHook<useAlertsHistoryProps, UseAlertsHistory>(
() =>
useAlertsHistory({
http,
featureIds: [AlertConsumers.APM],
ruleId,
dateRange: { from: start, to: end },
instanceId: '*',
}),
{
wrapper,
@ -198,7 +240,6 @@ describe('useAlertsHistory', () => {
body:
'{"size":0,"feature_ids":["apm"],"query":{"bool":{"must":[' +
'{"term":{"kibana.alert.rule.uuid":"cfd36e60-ef22-11ed-91eb-b7893acacfe2"}},' +
'{"term":{"kibana.alert.group.value":{"value":"host=1"}}},' +
'{"range":{"kibana.alert.time_range":{"from":"2023-04-10T00:00:00.000Z","to":"2023-05-10T00:00:00.000Z"}}}]}},' +
'"aggs":{"histogramTriggeredAlerts":{"date_histogram":{"field":"kibana.alert.start","fixed_interval":"1d",' +
'"extended_bounds":{"min":"2023-04-10T00:00:00.000Z","max":"2023-05-10T00:00:00.000Z"}}},' +

View file

@ -6,10 +6,10 @@
*/
import { type HttpSetup } from '@kbn/core/public';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { AggregationsDateHistogramBucketKeys } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
ALERT_DURATION,
ALERT_INSTANCE_ID,
ALERT_RULE_UUID,
ALERT_START,
ALERT_STATUS,
@ -27,7 +27,7 @@ export interface Props {
from: string;
to: string;
};
queries?: QueryDslQueryContainer[];
instanceId?: string;
}
interface FetchAlertsHistory {
@ -47,12 +47,13 @@ export const EMPTY_ALERTS_HISTORY = {
histogramTriggeredAlerts: [] as AggregationsDateHistogramBucketKeys[],
avgTimeToRecoverUS: 0,
};
export function useAlertsHistory({
featureIds,
ruleId,
dateRange,
http,
queries,
instanceId,
}: Props): UseAlertsHistory {
const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({
queryKey: ['useAlertsHistory'],
@ -66,7 +67,7 @@ export function useAlertsHistory({
ruleId,
dateRange,
signal,
queries,
instanceId,
});
},
refetchOnWindowFocus: false,
@ -103,7 +104,7 @@ export async function fetchTriggeredAlertsHistory({
ruleId,
dateRange,
signal,
queries = [],
instanceId,
}: {
featureIds: ValidFeatureId[];
http: HttpSetup;
@ -113,7 +114,7 @@ export async function fetchTriggeredAlertsHistory({
to: string;
};
signal?: AbortSignal;
queries?: QueryDslQueryContainer[];
instanceId?: string;
}): Promise<FetchAlertsHistory> {
try {
const responseES = await http.post<AggsESResponse>(`${BASE_RAC_ALERTS_API_PATH}/find`, {
@ -129,7 +130,15 @@ export async function fetchTriggeredAlertsHistory({
[ALERT_RULE_UUID]: ruleId,
},
},
...queries,
...(instanceId && instanceId !== '*'
? [
{
term: {
[ALERT_INSTANCE_ID]: instanceId,
},
},
]
: []),
{
range: {
[ALERT_TIME_RANGE]: dateRange,

View file

@ -12,6 +12,7 @@ import {
ALERT_END,
ALERT_EVALUATION_THRESHOLD,
ALERT_EVALUATION_VALUE,
ALERT_INSTANCE_ID,
ALERT_RULE_TYPE_ID,
ALERT_RULE_UUID,
ALERT_START,
@ -220,6 +221,7 @@ export function AlertDetailsAppSection({
<EuiFlexItem grow={false}>
<LatencyAlertsHistoryChart
ruleId={alert.fields[ALERT_RULE_UUID]}
alertInstanceId={alert.fields[ALERT_INSTANCE_ID]}
serviceName={serviceName}
start={historicalRange.start}
end={historicalRange.end}

View file

@ -51,6 +51,7 @@ interface LatencyAlertsHistoryChartProps {
environment: string;
timeZone: string;
ruleId: string;
alertInstanceId?: string;
}
export function LatencyAlertsHistoryChart({
serviceName,
@ -61,6 +62,7 @@ export function LatencyAlertsHistoryChart({
environment,
timeZone,
ruleId,
alertInstanceId,
}: LatencyAlertsHistoryChartProps) {
const preferred = usePreferredDataSourceAndBucketSize({
start,
@ -144,6 +146,7 @@ export function LatencyAlertsHistoryChart({
featureIds: [AlertConsumers.APM],
ruleId,
dateRange: { from: start, to: end },
instanceId: alertInstanceId,
});
if (isError) {

View file

@ -28,8 +28,17 @@ import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana';
import { type PartialCriterion } from '../../../../../../common/alerting/logs/log_threshold';
import { CriterionPreview } from '../../expression_editor/criterion_preview_chart';
import { PartialRuleParams } from '../../../../../../common/alerting/logs/log_threshold';
import type { Group } from '../types';
const LogsHistoryChart = ({ rule }: { rule: Rule<PartialRuleParams> }) => {
const LogsHistoryChart = ({
rule,
instanceId,
groups,
}: {
rule: Rule<PartialRuleParams>;
instanceId?: string;
groups?: Group[];
}) => {
const { http, notifications } = useKibanaContextForPlugin().services;
// Show the Logs History Chart ONLY if we have one criteria
// So always pull the first criteria
@ -42,6 +51,7 @@ const LogsHistoryChart = ({ rule }: { rule: Rule<PartialRuleParams> }) => {
const executionTimeRange = {
gte: DateMath.parse(dateRange.from)!.valueOf(),
lte: DateMath.parse(dateRange.to, { roundUp: true })!.valueOf(),
buckets: 30,
};
const {
@ -53,6 +63,7 @@ const LogsHistoryChart = ({ rule }: { rule: Rule<PartialRuleParams> }) => {
featureIds: [AlertConsumers.LOGS],
ruleId: rule.id,
dateRange,
instanceId,
});
if (isError) {
@ -180,11 +191,12 @@ const LogsHistoryChart = ({ rule }: { rule: Rule<PartialRuleParams> }) => {
markerPosition={Position.Top}
/>,
]}
ruleParams={rule.params}
ruleParams={{ ...rule.params, timeSize: 1, timeUnit: 'd' }}
logViewReference={rule.params.logView}
chartCriterion={criteria as PartialCriterion}
showThreshold={true}
executionTimeRange={executionTimeRange}
filterSeriesByGroupName={groups?.map((group) => group.value).join(', ')}
/>
</EuiPanel>
);

View file

@ -12,6 +12,7 @@ import {
ALERT_CONTEXT,
ALERT_END,
ALERT_EVALUATION_VALUE,
ALERT_INSTANCE_ID,
ALERT_START,
} from '@kbn/rule-data-utils';
import moment from 'moment';
@ -34,6 +35,7 @@ import { Threshold } from '../../../common/components/threshold';
import { LogRateAnalysis } from './components/log_rate_analysis';
import { LogThresholdCountChart, LogThresholdRatioChart } from './components/threhsold_chart';
import { useLicense } from '../../../../hooks/use_license';
import type { Group } from './types';
const LogsHistoryChart = React.lazy(() => import('./components/logs_history_chart'));
const formatThreshold = (threshold: number) => String(threshold);
@ -63,6 +65,16 @@ const AlertDetailsAppSection = ({
.filter(identity)
.join(' AND ')
: '';
const groups: Group[] | undefined = rule.params.groupBy
? rule.params.groupBy.flatMap((field) => {
const value: string = get(
alert.fields[ALERT_CONTEXT],
['groupByKeys', ...field.split('.')],
null
);
return value ? { field, value } : [];
})
: undefined;
const { derivedDataView } = useLogView({
initialLogViewReference: rule.params.logView,
@ -230,7 +242,12 @@ const AlertDetailsAppSection = ({
rule.params.criteria.length === 1 && (
<EuiFlexItem>
<LogsHistoryChart
rule={{ ...rule, params: { ...rule.params, timeSize: 12, timeUnit: 'h' } }}
rule={{
...rule,
params: { ...rule.params, timeSize: 12, timeUnit: 'h' },
}}
instanceId={alert.fields[ALERT_INSTANCE_ID]}
groups={groups}
/>
</EuiFlexItem>
)

View file

@ -14,3 +14,8 @@ export interface AlertDetailsAppSectionProps {
alert: TopAlert<Record<string, any>>;
setAlertSummaryFields: React.Dispatch<React.SetStateAction<AlertSummaryField[] | undefined>>;
}
export interface Group {
field: string;
value: string;
}

View file

@ -61,7 +61,7 @@ interface Props {
showThreshold: boolean;
executionTimeRange?: ExecutionTimeRange;
annotations?: Array<ReactElement<typeof RectAnnotation | typeof LineAnnotation>>;
filterSeriesByGroupName?: string[];
filterSeriesByGroupName?: string;
}
export const CriterionPreview: React.FC<Props> = ({
@ -107,7 +107,9 @@ export const CriterionPreview: React.FC<Props> = ({
return (
<CriterionPreviewChart
buckets={
!chartAlertParams.groupBy || chartAlertParams.groupBy.length === 0
executionTimeRange?.buckets
? executionTimeRange.buckets
: !chartAlertParams.groupBy || chartAlertParams.groupBy.length === 0
? NUM_BUCKETS
: NUM_BUCKETS / 4
} // Display less data for groups due to space limitations
@ -130,7 +132,7 @@ interface ChartProps {
showThreshold: boolean;
executionTimeRange?: ExecutionTimeRange;
annotations?: Array<ReactElement<typeof RectAnnotation | typeof LineAnnotation>>;
filterSeriesByGroupName?: string[];
filterSeriesByGroupName?: string;
}
const CriterionPreviewChart: React.FC<ChartProps> = ({
@ -190,7 +192,7 @@ const CriterionPreviewChart: React.FC<ChartProps> = ({
return series;
}
if (filterSeriesByGroupName && filterSeriesByGroupName.length) {
return series.filter((item) => filterSeriesByGroupName.includes(item.id));
return series.filter((item) => filterSeriesByGroupName === item.id);
}
const sortedByMax = series.sort((a, b) => {
const aMax = Math.max(...a.points.map((point) => point.value));

View file

@ -135,6 +135,7 @@ export interface InfraHttpError extends IHttpFetchError {
export interface ExecutionTimeRange {
gte: number;
lte: number;
buckets?: number;
}
type PropsOf<T> = T extends React.ComponentType<infer ComponentProps> ? ComponentProps : never;

View file

@ -11,7 +11,7 @@ import { Group } from '../types';
/*
* groupFieldName
* In some cases, like AAD indices, the field name for group value is differen from group.field,
* In some cases, like AAD indices, the field name for group value is different from group.field,
* in AAD case, it is ALERT_GROUP_VALUE (`kibana.alert.group.value`). groupFieldName allows
* passing a different field name to be used in the query.
*/

View file

@ -22,13 +22,10 @@ import {
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALERT_GROUP, ALERT_GROUP_VALUE, type AlertConsumers } from '@kbn/rule-data-utils';
import { ALERT_GROUP, ALERT_INSTANCE_ID, type AlertConsumers } from '@kbn/rule-data-utils';
import { useAlertsHistory } from '@kbn/observability-alert-details';
import { convertTo } from '../../../../../common/utils/formatters';
import {
getGroupFilters,
getGroupQueries,
} from '../../../../../common/custom_threshold_rule/helpers/get_group';
import { getGroupFilters } from '../../../../../common/custom_threshold_rule/helpers/get_group';
import { useKibana } from '../../../../utils/kibana_react';
import { AlertParams } from '../../types';
import { RuleConditionChart } from '../rule_condition_chart/rule_condition_chart';
@ -54,6 +51,7 @@ export function AlertHistoryChart({ rule, dataView, alert }: Props) {
const ruleParams = rule.params as RuleTypeParams & AlertParams;
const criterion = rule.params.criteria[0];
const groups = alert.fields[ALERT_GROUP];
const instanceId = alert.fields[ALERT_INSTANCE_ID];
const featureIds = [rule.consumer as AlertConsumers];
const {
@ -65,7 +63,7 @@ export function AlertHistoryChart({ rule, dataView, alert }: Props) {
featureIds,
ruleId: rule.id,
dateRange,
queries: getGroupQueries(groups, ALERT_GROUP_VALUE),
instanceId,
});
// Only show alert history chart if there is only one condition

View file

@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useAlertsHistory } from '@kbn/observability-alert-details';
import rison from '@kbn/rison';
import { ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils';
import { ALERT_INSTANCE_ID, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils';
import { GetSLOResponse } from '@kbn/slo-schema';
import moment from 'moment';
import React from 'react';
@ -52,6 +52,7 @@ export function AlertsHistoryPanel({ rule, slo, alert, isLoading }: Props) {
to: 'now',
},
http,
instanceId: alert.fields[ALERT_INSTANCE_ID],
});
const actionGroup = getActionGroupFromReason(alert.reason);