From acd3e83c1c3db3d843b140897059afd565f13e0f Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Wed, 17 Apr 2024 08:55:01 +0200 Subject: [PATCH] Alerting: Take receivers into account when custom grouping Alertmanager groups (#86127) * Take receiver into account when custom grouping Alertmanager alert groups * Fix and add tests --- .../alerting/unified/AlertGroups.test.tsx | 57 +++++++++++++++---- .../unified/hooks/useGroupedAlerts.ts | 50 ++++++++++------ .../plugins/datasource/alertmanager/types.ts | 6 +- 3 files changed, 79 insertions(+), 34 deletions(-) diff --git a/public/app/features/alerting/unified/AlertGroups.test.tsx b/public/app/features/alerting/unified/AlertGroups.test.tsx index d6237e418f5..5771a296785 100644 --- a/public/app/features/alerting/unified/AlertGroups.test.tsx +++ b/public/app/features/alerting/unified/AlertGroups.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; @@ -56,6 +56,7 @@ const ui = { groupByContainer: byTestId('group-by-container'), groupByInput: byRole('combobox', { name: /group by label keys/i }), clearButton: byRole('button', { name: 'Clear filters' }), + loadingIndicator: byText('Loading notifications'), }; describe('AlertGroups', () => { @@ -66,20 +67,24 @@ describe('AlertGroups', () => { AccessControlAction.AlertingInstancesExternalRead, AccessControlAction.AlertingRuleRead, ]); - - mocks.api.fetchAlertGroups.mockImplementation(() => { - return Promise.resolve([ - mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }), - mockAlertGroup(), - ]); - }); }); beforeEach(() => { setDataSourceSrv(new MockDataSourceSrv(dataSources)); }); + afterEach(() => { + mocks.api.fetchAlertGroups.mockClear(); + }); + it('loads and shows groups', async () => { + mocks.api.fetchAlertGroups.mockImplementation(() => { + return Promise.resolve([ + mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }), + mockAlertGroup(), + ]); + }); + renderAmNotifications(); await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled()); @@ -105,9 +110,12 @@ describe('AlertGroups', () => { mockAlertGroup({ labels: { region }, alerts: [ - mockAlertmanagerAlert({ labels: { region, appName: 'billing', env: 'production' } }), - mockAlertmanagerAlert({ labels: { region, appName: 'auth', env: 'staging', uniqueLabel: 'true' } }), - mockAlertmanagerAlert({ labels: { region, appName: 'frontend', env: 'production' } }), + mockAlertmanagerAlert({ fingerprint: '1', labels: { region, appName: 'billing', env: 'production' } }), + mockAlertmanagerAlert({ + fingerprint: '2', + labels: { region, appName: 'auth', env: 'staging', uniqueLabel: 'true' }, + }), + mockAlertmanagerAlert({ fingerprint: '3', labels: { region, appName: 'frontend', env: 'production' } }), ], }) ); @@ -161,6 +169,33 @@ describe('AlertGroups', () => { expect(groups[1]).toHaveTextContent('uniqueLabeltrue'); }); + it('should split custom grouping groups with the same label by receiver', async () => { + // The same alert is repeated in two groups with different receivers + const alert = mockAlertmanagerAlert({ + fingerprint: '1', + labels: { region: 'NASA', appName: 'billing' }, + receivers: [{ name: 'slack' }, { name: 'email' }], + }); + const amGroups = [ + mockAlertGroup({ receiver: { name: 'slack' }, labels: { region: 'NASA' }, alerts: [alert] }), + mockAlertGroup({ receiver: { name: 'email' }, labels: { region: 'NASA' }, alerts: [alert] }), + ]; + mocks.api.fetchAlertGroups.mockResolvedValue(amGroups); + + const user = userEvent.setup(); + + renderAmNotifications(); + await waitForElementToBeRemoved(ui.loadingIndicator.query()); + + await user.type(ui.groupByInput.get(), 'appName{enter}'); + + const groups = await ui.group.findAll(); + + expect(groups).toHaveLength(2); + expect(groups[0]).toHaveTextContent('appNamebillingDelivered to slack'); + expect(groups[1]).toHaveTextContent('appNamebillingDelivered to email'); + }); + it('should combine multiple ungrouped groups', async () => { mocks.api.fetchAlertGroups.mockImplementation(() => { const groups = [ diff --git a/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts b/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts index 4a0897e45ce..2c18c077f90 100644 --- a/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts +++ b/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts @@ -27,31 +27,45 @@ export const useGroupedAlerts = (groups: AlertmanagerGroup[], groupBy: string[]) return groups; } } - const alerts = groups.flatMap(({ alerts }) => alerts); + + // api/v2/alerts/groups returns alerts grouped by labels AND receiver. + // It means that the same alert can be in multiple groups if it has multiple receivers. + // Hence, to get the list of unique alerts we need to get unique alerts by fingerprint. + const alerts = uniqBy( + groups.flatMap(({ alerts }) => alerts), + (alert) => alert.fingerprint + ); return alerts.reduce((groupings, alert) => { const alertContainsGroupings = groupBy.every((groupByLabel) => Object.keys(alert.labels).includes(groupByLabel)); if (alertContainsGroupings) { - const existingGrouping = groupings.find((group) => { - return groupBy.every((groupKey) => { - return group.labels[groupKey] === alert.labels[groupKey]; - }); - }); - if (!existingGrouping) { - const labels = groupBy.reduce((acc, key) => { + // We need to create a group for each receiver. This is how Alertmanager groups alerts. + // Alertmanager not only does grouping by labels but also by receiver. + const receiverAlertGroups = alert.receivers.map((receiver) => ({ + alerts: [alert], + labels: groupBy.reduce((acc, key) => { acc = { ...acc, [key]: alert.labels[key] }; return acc; - }, {}); - groupings.push({ - alerts: [alert], - labels, - receiver: { - name: 'NONE', - }, + }, {}), + receiver, + })); + + // Merge the same groupings - groupings are the same if they have the same labels and receiver + receiverAlertGroups.forEach((receiverAlertGroup) => { + const existingGroup = groupings.find((grouping) => { + return ( + Object.keys(receiverAlertGroup.labels).every( + (key) => grouping.labels[key] === receiverAlertGroup.labels[key] + ) && grouping.receiver.name === receiverAlertGroup.receiver.name + ); }); - } else { - existingGrouping.alerts.push(alert); - } + + if (existingGroup) { + existingGroup.alerts.push(alert); + } else { + groupings.push(receiverAlertGroup); + } + }); } else { const noGroupingGroup = groupings.find((group) => Object.keys(group.labels).length === 0); if (!noGroupingGroup) { diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index ba3f6594ffe..76254f3d170 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -217,11 +217,7 @@ export type AlertmanagerAlert = { generatorURL?: string; labels: { [key: string]: string }; annotations: { [key: string]: string }; - receivers: [ - { - name: string; - }, - ]; + receivers: Array<{ name: string }>; fingerprint: string; status: { state: AlertState;