From 41edce75f28df5dff27c0628a5a5354513d60464 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:45:51 +0000 Subject: [PATCH] fix(opsgenie): close recovered alerts by alias (#2683) --- .../notifications/opsgenie/src/index.test.ts | 78 ++++++++++++++++++- packages/notifications/opsgenie/src/index.ts | 42 +++++----- 2 files changed, 94 insertions(+), 26 deletions(-) diff --git a/packages/notifications/opsgenie/src/index.test.ts b/packages/notifications/opsgenie/src/index.test.ts index ba678823..67712e53 100644 --- a/packages/notifications/opsgenie/src/index.test.ts +++ b/packages/notifications/opsgenie/src/index.test.ts @@ -1,9 +1,9 @@ import { selectNotificationSchema } from "@openstatus/db/src/schema"; import { expect } from "@std/expect"; import { afterEach, beforeEach, describe, test } from "@std/testing/bdd"; -import { assertSpyCalls, stub, type Stub } from "@std/testing/mock"; +import { assertSpyCalls, type Stub, stub } from "@std/testing/mock"; -import { sendAlert, sendDegraded, sendTest } from "./index"; +import { sendAlert, sendDegraded, sendRecovery, sendTest } from "./index"; describe("OpsGenie Notifications", () => { let fetchMock: Stub; @@ -134,6 +134,80 @@ describe("OpsGenie Notifications", () => { expect(body.message).toBe("API Health Check is degraded"); }); + for (const { region, message, url } of [ + { + region: "us", + message: "Service recovered", + url: "https://api.opsgenie.com/v2/alerts/monitor-1/close?identifierType=alias", + }, + { + region: "eu", + message: undefined, + url: "https://api.eu.opsgenie.com/v2/alerts/monitor-1/close?identifierType=alias", + }, + ] as const) { + test(`Recovery closes the monitor alias in ${region}`, async () => { + fetchMock.restore(); + fetchMock = stub(globalThis, "fetch", () => + Promise.resolve(new Response(null, { status: 202 })), + ); + + await sendRecovery({ + // @ts-expect-error + monitor: createMockMonitor(), + notification: selectNotificationSchema.parse( + createMockNotification(region), + ), + statusCode: 200, + message, + cronTimestamp: Date.now(), + }); + + assertSpyCalls(fetchMock, 1); + const [requestUrl, options] = fetchMock.calls[0].args; + expect(requestUrl).toBe(url); + expect(options.method).toBe("POST"); + expect(options.headers["Content-Type"]).toBe("application/json"); + expect(options.headers.Authorization).toBe("GenieKey test-api-key-123"); + expect(JSON.parse(options.body)).toEqual({ source: "OpenStatus" }); + }); + } + + test("Recovery rejects an unsuccessful close response", async () => { + fetchMock.restore(); + fetchMock = stub(globalThis, "fetch", () => + Promise.resolve(new Response(null, { status: 503 })), + ); + + await expect( + sendRecovery({ + // @ts-expect-error + monitor: createMockMonitor(), + notification: selectNotificationSchema.parse(createMockNotification()), + message: "Service recovered", + cronTimestamp: Date.now(), + }), + ).rejects.toThrow("503"); + assertSpyCalls(fetchMock, 1); + }); + + test("Recovery propagates a rejected close request", async () => { + const error = new Error("Connection reset"); + fetchMock.restore(); + fetchMock = stub(globalThis, "fetch", () => Promise.reject(error)); + + await expect( + sendRecovery({ + // @ts-expect-error + monitor: createMockMonitor(), + notification: selectNotificationSchema.parse(createMockNotification()), + message: "Service recovered", + cronTimestamp: Date.now(), + }), + ).rejects.toBe(error); + assertSpyCalls(fetchMock, 1); + }); + test("Handle fetch error gracefully", async () => { fetchMock.restore(); fetchMock = stub(globalThis, "fetch", () => diff --git a/packages/notifications/opsgenie/src/index.ts b/packages/notifications/opsgenie/src/index.ts index cc49945f..ecc84624 100644 --- a/packages/notifications/opsgenie/src/index.ts +++ b/packages/notifications/opsgenie/src/index.ts @@ -1,6 +1,10 @@ import type { NotificationContext } from "@openstatus/notification-base"; -import { OpsGeniePayloadAlert, OpsGenieSchema } from "./schema"; +import { + OpsGenieCloseAlert, + OpsGeniePayloadAlert, + OpsGenieSchema, +} from "./schema"; export const sendAlert = async ({ monitor, @@ -84,37 +88,27 @@ export const sendDegraded = async ({ export const sendRecovery = async ({ monitor, notification, - statusCode, - message, }: NotificationContext) => { const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); const url = opsgenie.region === "eu" - ? `https://api.eu.opsgenie.com/v2/alerts/${monitor.id}/close` - : `https://api.opsgenie.com/v2/alerts/${monitor.id}/close`; + ? `https://api.eu.opsgenie.com/v2/alerts/${monitor.id}/close?identifierType=alias` + : `https://api.opsgenie.com/v2/alerts/${monitor.id}/close?identifierType=alias`; - const event = OpsGeniePayloadAlert.parse({ - alias: `${monitor.id}`, - message: `${monitor.name} has recovered`, - description: message, - details: { - message, - status: statusCode, + const event = OpsGenieCloseAlert.parse({}); + const res = await fetch(url, { + method: "POST", + body: JSON.stringify(event), + headers: { + "Content-Type": "application/json", + Authorization: `GenieKey ${opsgenie.apiKey}`, }, }); - try { - await fetch(url, { - method: "POST", - body: JSON.stringify(event), - headers: { - "Content-Type": "application/json", - Authorization: `GenieKey ${opsgenie.apiKey}`, - }, - }); - } catch (err) { - console.log(err); - // Do something + if (!res.ok) { + throw new Error( + `Failed to close OpsGenie alert: ${res.status} ${res.statusText}`, + ); } }; export const sendTest = async (props: { -- 2.51.2