[Incident management] Callout for alerts that triggered around the same time (#223473)

## Summary

Implements #213020
Partially implements filter bar seen with #213015


This PR adds a callout on the alert details page to encourage users to
visit the related alerts page when at least one alert was triggered
within 30 minutes of the current alert. If no alerts were triggered, the
message remains without a call to action.


https://github.com/user-attachments/assets/23b2d3e9-353b-45e1-a007-d188db5617fc



## Testing

The related alert query usually find alerts that were raised within a
day of each other. To find alerts that were raised within a few minutes,
try creating an SLO with a chosen groupBy field that will easily violate
a burn rate rule. Alerts should be triggered for each instance within
seconds. Once the filter is executed, these alerts should appear without
alerts that were triggered earlier in the day.
This commit is contained in:
Bailey Cash 2025-06-24 05:03:20 -04:00 committed by GitHub
parent a3e4c2e770
commit 7da827e8d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 331 additions and 22 deletions

View file

@ -68,6 +68,10 @@ export interface SearchAlertsParams {
* The page size to fetch
*/
pageSize: number;
/**
* Force using the default context, otherwise use the AlertQueryContext
*/
skipAlertsQueryContext?: boolean;
/**
* The minimum score to apply to the query
*/

View file

@ -27,7 +27,11 @@ export const queryKeyPrefix = ['alerts', searchAlerts.name];
* When testing components that depend on this hook, prefer mocking the {@link searchAlerts} function instead of the hook itself.
* @external https://tanstack.com/query/v4/docs/framework/react/guides/testing
*/
export const useSearchAlertsQuery = ({ data, ...params }: UseSearchAlertsQueryParams) => {
export const useSearchAlertsQuery = ({
data,
skipAlertsQueryContext,
...params
}: UseSearchAlertsQueryParams) => {
const {
ruleTypeIds,
consumers,
@ -64,7 +68,7 @@ export const useSearchAlertsQuery = ({ data, ...params }: UseSearchAlertsQueryPa
trackScores,
}),
refetchOnWindowFocus: false,
context: AlertsQueryContext,
context: skipAlertsQueryContext ? undefined : AlertsQueryContext,
enabled: ruleTypeIds.length > 0,
// To avoid flash of empty state with pagination, see https://tanstack.com/query/latest/docs/framework/react/guides/paginated-queries#better-paginated-queries-with-placeholderdata
keepPreviousData: true,

View file

@ -204,6 +204,7 @@ describe('Alert details', () => {
expect(alertDetails.queryByTestId('alert-summary-container')).toBeFalsy();
expect(alertDetails.queryByTestId('overviewTab')).toBeTruthy();
expect(alertDetails.queryByTestId('metadataTab')).toBeTruthy();
expect(alertDetails.queryByTestId('relatedAlertsTab')).toBeTruthy();
});
it('should show Metadata tab', async () => {

View file

@ -6,7 +6,7 @@
*/
import React, { useCallback, useEffect, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
@ -60,6 +60,8 @@ import StaleAlert from './components/stale_alert';
import { RelatedDashboards } from './components/related_dashboards';
import { getAlertTitle } from '../../utils/format_alert_title';
import { AlertSubtitle } from './components/alert_subtitle';
import { ProximalAlertsCallout } from './proximal_alerts_callout';
import { useTabId } from './hooks/use_tab_id';
import { useRelatedDashboards } from './hooks/use_related_dashboards';
interface AlertDetailsPathParams {
@ -74,7 +76,6 @@ const defaultBreadcrumb = i18n.translate('xpack.observability.breadcrumbs.alertD
export const LOG_DOCUMENT_COUNT_RULE_TYPE_ID = 'logs.alert.document.count';
export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold';
export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold';
const ALERT_DETAILS_TAB_URL_STORAGE_KEY = 'tabId';
const TAB_IDS = [
'overview',
@ -91,6 +92,7 @@ const isTabId = (value: string): value is TabId => {
};
export function AlertDetails() {
const { services } = useKibana();
const {
cases: {
helpers: { canUseCases },
@ -101,12 +103,11 @@ export function AlertDetails() {
observabilityAIAssistant,
uiSettings,
serverless,
} = useKibana().services;
const { search } = useLocation();
const history = useHistory();
} = services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const { alertId } = useParams<AlertDetailsPathParams>();
const { getUrlTabId, setUrlTabId } = useTabId();
const urlTabId = getUrlTabId();
const {
isLoadingRelatedDashboards,
suggestedDashboards,
@ -135,17 +136,17 @@ export function AlertDetails() {
const { euiTheme } = useEuiTheme();
const [sources, setSources] = useState<AlertDetailsSource[]>();
const [activeTabId, setActiveTabId] = useState<TabId>();
const handleSetTabId = async (tabId: TabId) => {
setActiveTabId(tabId);
let searchParams = new URLSearchParams(search);
if (tabId === 'related_alerts') {
searchParams.set(ALERT_DETAILS_TAB_URL_STORAGE_KEY, tabId);
setUrlTabId(tabId, true, {
filterProximal: 'true',
});
} else {
searchParams = new URLSearchParams();
searchParams.set(ALERT_DETAILS_TAB_URL_STORAGE_KEY, tabId);
setUrlTabId(tabId, true);
}
history.replace({ search: searchParams.toString() });
};
useEffect(() => {
@ -171,11 +172,9 @@ export function AlertDetails() {
if (alertDetail) {
setRuleTypeModel(ruleTypeRegistry.get(alertDetail?.formatted.fields[ALERT_RULE_TYPE_ID]!));
setAlertStatus(alertDetail?.formatted?.fields[ALERT_STATUS] as AlertStatus);
const searchParams = new URLSearchParams(search);
const urlTabId = searchParams.get(ALERT_DETAILS_TAB_URL_STORAGE_KEY);
setActiveTabId(urlTabId && isTabId(urlTabId) ? urlTabId : 'overview');
}
}, [alertDetail, ruleTypeRegistry, search]);
}, [alertDetail, ruleTypeRegistry, urlTabId]);
useBreadcrumbs(
[
@ -199,6 +198,10 @@ export function AlertDetails() {
setAlertStatus(ALERT_STATUS_UNTRACKED);
}, []);
const showRelatedAlertsFromCallout = () => {
handleSetTabId('related_alerts');
};
usePageReady({
isRefreshing: isLoading,
isReady: !isLoading && !!alertDetail && activeTabId === 'overview',
@ -253,6 +256,10 @@ export function AlertDetails() {
<EuiSpacer size="m" />
<EuiFlexGroup direction="column" gutterSize="m">
<ProximalAlertsCallout
alertDetail={alertDetail}
switchTabs={showRelatedAlertsFromCallout}
/>
<SourceBar alert={alertDetail.formatted} sources={sources} />
<AlertDetailContextualInsights alert={alertDetail} />
{rule && alertDetail.formatted && (
@ -273,6 +280,11 @@ export function AlertDetails() {
</>
) : (
<EuiPanel hasShadow={false} data-test-subj="overviewTabPanel" paddingSize="none">
<EuiSpacer size="l" />
<ProximalAlertsCallout
alertDetail={alertDetail}
switchTabs={showRelatedAlertsFromCallout}
/>
<EuiSpacer size="l" />
<AlertDetailContextualInsights alert={alertDetail} />
<EuiSpacer size="l" />

View file

@ -11,7 +11,7 @@ import { ALERT_START, ALERT_UUID } from '@kbn/rule-data-utils';
import { AlertsTable } from '@kbn/response-ops-alerts-table';
import { SortOrder } from '@elastic/elasticsearch/lib/api/types';
import { getRelatedColumns } from './get_related_columns';
import { useBuildRelatedAlertsQuery } from '../../hooks/related_alerts/use_build_related_alerts_query';
import { getBuildRelatedAlertsQuery } from '../../hooks/related_alerts/get_build_related_alerts_query';
import { AlertData } from '../../../../hooks/use_fetch_alert_detail';
import {
GetObservabilityAlertsTableProp,
@ -25,6 +25,8 @@ import { AlertsFlyoutFooter } from '../../../../components/alerts_flyout/alerts_
import { OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES } from '../../../../../common/constants';
import { AlertsTableCellValue } from '../../../../components/alerts_table/common/cell_value';
import { casesFeatureIdV2 } from '../../../../../common';
import { useFilterProximalParam } from '../../hooks/use_filter_proximal_param';
import { RelatedAlertsTableFilter } from './related_alerts_table_filter';
interface Props {
alertData: AlertData;
@ -52,14 +54,15 @@ const RELATED_ALERTS_TABLE_ID = 'xpack.observability.alerts.relatedAlerts';
export function RelatedAlertsTable({ alertData }: Props) {
const { formatted: alert } = alertData;
const esQuery = useBuildRelatedAlertsQuery({ alert });
const { filterProximal } = useFilterProximalParam();
const esQuery = getBuildRelatedAlertsQuery({ alert, filterProximal });
const { observabilityRuleTypeRegistry, config } = usePluginContext();
const services = useKibana().services;
return (
<EuiFlexGroup direction="column" gutterSize="m">
<EuiSpacer size="s" />
<RelatedAlertsTableFilter />
<AlertsTable<ObservabilityAlertsTableContext>
id={RELATED_ALERTS_TABLE_ID}
query={esQuery}

View file

@ -0,0 +1,45 @@
/*
* 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 { EuiCheckbox, EuiFlexGroup, EuiFormRow, EuiPanel, EuiText } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { useFilterProximalParam } from '../../hooks/use_filter_proximal_param';
export function RelatedAlertsTableFilter() {
const { filterProximal, setProximalFilterParam } = useFilterProximalParam();
return (
<EuiPanel paddingSize="m" hasShadow={false} color="subdued">
<EuiFlexGroup direction="row" alignItems="center" justifyContent="flexStart">
<EuiText size="s">
<strong>
{i18n.translate('xpack.observability.alerts.relatedAlerts.filtersLabel', {
defaultMessage: 'Filters',
})}
</strong>
</EuiText>
<EuiFormRow fullWidth>
<EuiCheckbox
label={i18n.translate(
'xpack.observability.alerts.relatedAlerts.proximityCheckboxLabel',
{
defaultMessage: 'Created around the same time',
}
)}
checked={filterProximal}
onChange={(event) => {
setProximalFilterParam(event.target.checked);
}}
id={'proximal-alerts-checkbox'}
data-test-subj="proximal-alerts-checkbox"
/>
</EuiFormRow>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -23,9 +23,13 @@ import { TopAlert } from '../../../../typings/alerts';
interface Props {
alert: TopAlert<ObservabilityFields>;
filterProximal: boolean;
}
export function useBuildRelatedAlertsQuery({ alert }: Props): QueryDslQueryContainer {
export function getBuildRelatedAlertsQuery({
alert,
filterProximal,
}: Props): QueryDslQueryContainer {
const groups = alert.fields[ALERT_GROUP];
const shouldGroups: QueryDslQueryContainer[] = [];
groups?.forEach(({ field, value }) => {
@ -58,14 +62,22 @@ export function useBuildRelatedAlertsQuery({ alert }: Props): QueryDslQueryConta
const tags = alert.fields[ALERT_RULE_TAGS] ?? [];
const instanceId = alert.fields[ALERT_INSTANCE_ID]?.split(',') ?? [];
const range = filterProximal ? [30, 'minutes'] : [1, 'days'];
return {
bool: {
filter: [
{
range: {
[ALERT_START]: {
gte: startDate.clone().subtract(1, 'days').toISOString(),
lte: startDate.clone().add(1, 'days').toISOString(),
gte: startDate
.clone()
.subtract(...range)
.toISOString(),
lte: startDate
.clone()
.add(...range)
.toISOString(),
},
},
},

View file

@ -0,0 +1,23 @@
/*
* 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 { useHistory, useLocation } from 'react-router-dom';
export const useFilterProximalParam = () => {
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
const history = useHistory();
const setProximalFilterParam = (proximalFilter: boolean) => {
searchParams.set('filterProximal', String(proximalFilter));
history.replace({ search: searchParams.toString() });
};
const filterProximal = searchParams.get('filterProximal') === 'true';
return { filterProximal, setProximalFilterParam };
};

View file

@ -0,0 +1,32 @@
/*
* 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 { useSearchAlertsQuery } from '@kbn/alerts-ui-shared/src/common/hooks/use_search_alerts_query';
import {
OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES,
observabilityAlertFeatureIds,
} from '../../../../common/constants';
import { AlertData } from '../../../hooks/use_fetch_alert_detail';
import { useKibana } from '../../../utils/kibana_react';
import { getBuildRelatedAlertsQuery } from './related_alerts/get_build_related_alerts_query';
export const useFindProximalAlerts = (alertDetail: AlertData) => {
const { services } = useKibana();
const esQuery = getBuildRelatedAlertsQuery({
alert: alertDetail.formatted,
filterProximal: true,
});
return useSearchAlertsQuery({
data: services.data,
ruleTypeIds: OBSERVABILITY_RULE_TYPE_IDS_WITH_SUPPORTED_STACK_RULE_TYPES,
consumers: observabilityAlertFeatureIds,
query: esQuery,
skipAlertsQueryContext: true,
});
};

View file

@ -0,0 +1,42 @@
/*
* 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 { useHistory, useLocation } from 'react-router-dom';
const ALERT_DETAILS_TAB_URL_STORAGE_KEY = 'tabId';
export const useTabId = () => {
const { search } = useLocation();
const history = useHistory();
const getUrlTabId = () => {
const searchParams = new URLSearchParams(search);
return searchParams.get(ALERT_DETAILS_TAB_URL_STORAGE_KEY);
};
const setUrlTabId = (
tabId: string,
overrideSearchState?: boolean,
newSearchState?: Record<string, string>
) => {
const searchParams = new URLSearchParams(overrideSearchState ? undefined : search);
searchParams.set(ALERT_DETAILS_TAB_URL_STORAGE_KEY, tabId);
if (newSearchState) {
Object.entries(newSearchState).forEach(([key, value]) => {
searchParams.set(key, value);
});
}
history.replace({ search: searchParams.toString() });
};
return {
getUrlTabId,
setUrlTabId,
};
};

View file

@ -0,0 +1,78 @@
/*
* 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 { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import React from 'react';
import { AlertData, useFetchAlertDetail } from '../../hooks/use_fetch_alert_detail';
import { useFindProximalAlerts } from './hooks/use_find_proximal_alerts';
import { ConfigSchema } from '../../plugin';
import { Subset } from '../../typings';
import { render } from '../../utils/test_helper';
import { alertDetail } from './mock/alert';
import { ProximalAlertsCallout } from './proximal_alerts_callout';
import { fireEvent } from '@testing-library/dom';
jest.mock('../../utils/kibana_react');
jest.mock('../../hooks/use_fetch_alert_detail');
jest.mock('./hooks/use_find_proximal_alerts');
jest.mock('@kbn/observability-shared-plugin/public');
jest.mock('@kbn/ebt-tools');
const useFetchAlertDetailMock = useFetchAlertDetail as jest.Mock;
const useFindProximalAlertsMock = useFindProximalAlerts as jest.Mock;
const config: Subset<ConfigSchema> = {
unsafe: {
alertDetails: {
uptime: { enabled: true },
},
},
};
describe('Proximal callout', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const switchTabs = jest.fn();
const renderCallout = (alert: AlertData) =>
render(
<IntlProvider locale="en">
<ProximalAlertsCallout alertDetail={alert} switchTabs={switchTabs} />
</IntlProvider>,
config
);
it('should recommend the user see more related alerts', async () => {
useFindProximalAlertsMock.mockReturnValue({
data: { total: 5 },
isError: false,
isLoading: false,
});
useFetchAlertDetailMock.mockReturnValue([false, alertDetail]);
const callout = renderCallout(alertDetail);
expect(callout.queryByTestId('see-proximal-alerts')).toBeTruthy();
fireEvent.click(callout.getByText('See related alerts'));
expect(switchTabs).toHaveBeenCalled();
});
it('should not recommend the user see more related alerts', async () => {
useFindProximalAlertsMock.mockReturnValue({
data: { total: 0 },
isError: false,
isLoading: false,
});
useFetchAlertDetailMock.mockReturnValue([false, alertDetail]);
const callout = renderCallout(alertDetail);
expect(callout.queryByTestId('see-proximal-alerts')).toBeFalsy();
});
});

View file

@ -0,0 +1,53 @@
/*
* 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 from 'react';
import { i18n } from '@kbn/i18n';
import { EuiCallOut, EuiIcon, EuiLink, useEuiTheme } from '@elastic/eui';
import { useFindProximalAlerts } from './hooks/use_find_proximal_alerts';
import { AlertData } from '../../hooks/use_fetch_alert_detail';
interface Props {
alertDetail: AlertData;
switchTabs: () => void;
}
export function ProximalAlertsCallout({ alertDetail, switchTabs }: Props) {
const { euiTheme } = useEuiTheme();
const { data, isError, isLoading } = useFindProximalAlerts(alertDetail);
const count = data?.total;
if (isLoading || isError || count === undefined || count < 0) {
return null;
}
return (
<EuiCallOut>
{i18n.translate('xpack.observability.alertDetails.proximalAlert.description', {
defaultMessage:
'{count, plural, one {# alert was} other {# alerts were}} created around the same time.',
values: {
count,
},
})}
{count > 0 && (
<EuiLink
data-test-id="see-proximal-alerts"
data-test-subj="see-proximal-alerts"
css={{ marginLeft: euiTheme.size.s }}
onClick={() => switchTabs()}
>
{i18n.translate('xpack.observability.alertDetails.proximalAlert.action', {
defaultMessage: 'See related alerts',
})}{' '}
<EuiIcon type={'arrowRight'} fontSize={'xs'} />
</EuiLink>
)}
</EuiCallOut>
);
}