Skip to content

Commit 42506b8

Browse files
committed
fix(ops): scope the PagerDuty cooldown to rows that actually paged
The cooldown counted every `external_notification.pagerduty` row for the dedupKey via `countRecentAuditEventsForActorAndTarget`, which filters neither `outcome` nor `detail`. But that eventType is written for every path: a real page (completed/triggered), a suppression (denied/cooldown_active), a failed page (error), and an auto-resolve (completed/resolved). So a single page self-renewed the window every cron tick (its own denied row kept the count > 0), a non-page silenced a real one, a failed page blocked its own retry, and an auto-resolve suppressed the re-page for a flapping condition. Add `countRecentAuditEventsForActorTargetAndOutcome`, shaped like its sibling with `outcome` + `detail` equality terms, and count only completed/triggered rows for both the loopover and legacy gittensory actors. Export `PAGERDUTY_AUDIT_DETAIL_TRIGGERED`/`_RESOLVED` and use them at both the write and read sites so the two spellings can never drift. The denied/error/resolved rows are still written (the operator's evidence); they are simply no longer counted. Closes #9695
1 parent afb3b84 commit 42506b8

3 files changed

Lines changed: 98 additions & 5 deletions

File tree

src/db/repositories.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3161,6 +3161,39 @@ export async function countRecentAuditEventsForActorAndTarget(env: Env, actor: s
31613161
return row.count;
31623162
}
31633163

3164+
/** Same as {@link countRecentAuditEventsForActorAndTarget} but additionally scoped to a specific `outcome` +
3165+
* `detail`. Backs the PagerDuty cooldown (#9695): a repeat page is suppressed only by rows that ACTUALLY paged
3166+
* (outcome "completed", detail "triggered"), never by the suppression rows or the auto-resolve rows that share
3167+
* the same eventType/targetKey — otherwise a single page (or even a `denied` non-page) self-renewed the window
3168+
* every cron tick and the repo never paged again. */
3169+
export async function countRecentAuditEventsForActorTargetAndOutcome(
3170+
env: Env,
3171+
actor: string,
3172+
eventType: string,
3173+
targetKey: string,
3174+
outcome: string,
3175+
detail: string,
3176+
sinceIso: string,
3177+
): Promise<number> {
3178+
const db = getDb(env.DB);
3179+
const [row] = await db
3180+
.select({ count: sql<number>`count(*)` })
3181+
.from(auditEvents)
3182+
.where(
3183+
and(
3184+
eq(auditEvents.actor, actor),
3185+
eq(auditEvents.eventType, eventType),
3186+
eq(auditEvents.targetKey, targetKey),
3187+
eq(auditEvents.outcome, outcome),
3188+
eq(auditEvents.detail, detail),
3189+
gte(auditEvents.createdAt, sinceIso),
3190+
),
3191+
);
3192+
/* v8 ignore next -- count(*) always returns exactly one row; the empty-array guard only satisfies the destructure type. */
3193+
if (!row) return 0;
3194+
return row.count;
3195+
}
3196+
31643197
/** Shared by every `targetKey` literal-prefix `LIKE` scan below ({@link countRecentAuditEventsForActorInRepo},
31653198
* {@link findHottestReviewTargetForRepo}) so a repo name containing a SQL `LIKE` wildcard (`%`/`_`) is always
31663199
* matched literally, never as a pattern -- e.g. `owner/foo_bar` must never spuriously match `owner/fooXbar#...`'s

src/services/notify-pagerduty.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { countRecentAuditEventsForActorAndTarget, recordAuditEvent } from "../db/repositories";
1+
import { countRecentAuditEventsForActorTargetAndOutcome, recordAuditEvent } from "../db/repositories";
22
import { errorMessage } from "../utils/json";
33
import { meetsSeverityThreshold, resolveSeverityThreshold, type LoopoverSeverity } from "./severity-threshold";
44

@@ -21,6 +21,12 @@ import { meetsSeverityThreshold, resolveSeverityThreshold, type LoopoverSeverity
2121
// every cron tick doesn't re-page every tick.
2222

2323
const PAGERDUTY_EVENTS_URL = "https://events.pagerduty.com/v2/enqueue";
24+
25+
// The audit `detail` written for a real page vs an auto-resolve. Both share outcome "completed", so the cooldown
26+
// must key off `detail` to count only rows that ACTUALLY paged (#9695). Exported + used at both the write and
27+
// the read site so the two spellings can never drift.
28+
export const PAGERDUTY_AUDIT_DETAIL_TRIGGERED = "triggered";
29+
export const PAGERDUTY_AUDIT_DETAIL_RESOLVED = "resolved";
2430
// PagerDuty routing/integration keys are 32 lowercase hex characters.
2531
const ROUTING_KEY_RE = /^[a-f0-9]{32}$/i;
2632
const DEFAULT_MIN_SEVERITY: PagerDutySeverity = "error";
@@ -182,8 +188,8 @@ export async function triggerPagerDutyIncident(
182188
// window can reach back across the deploy boundary. Querying both actors costs one extra indexed count and
183189
// removes the whole risk category rather than requiring a precisely-timed follow-up cleanup.
184190
const [recentPagesNewActor, recentPagesLegacyActor] = await Promise.all([
185-
countRecentAuditEventsForActorAndTarget(env, "loopover", "external_notification.pagerduty", params.dedupKey, cooldownSinceIso),
186-
countRecentAuditEventsForActorAndTarget(env, "gittensory", "external_notification.pagerduty", params.dedupKey, cooldownSinceIso),
191+
countRecentAuditEventsForActorTargetAndOutcome(env, "loopover", "external_notification.pagerduty", params.dedupKey, "completed", PAGERDUTY_AUDIT_DETAIL_TRIGGERED, cooldownSinceIso),
192+
countRecentAuditEventsForActorTargetAndOutcome(env, "gittensory", "external_notification.pagerduty", params.dedupKey, "completed", PAGERDUTY_AUDIT_DETAIL_TRIGGERED, cooldownSinceIso),
187193
]);
188194
const recentPages = recentPagesNewActor + recentPagesLegacyActor;
189195
if (recentPages > 0) {
@@ -211,7 +217,7 @@ export async function triggerPagerDutyIncident(
211217
signal: AbortSignal.timeout(5000),
212218
});
213219
if (!response.ok) throw new Error(`pagerduty_events_http_${response.status}`);
214-
await auditPagerDutyNotification(env, { repoFullName: params.repoFullName, dedupKey: params.dedupKey }, "completed", "triggered", { source: resolution.source });
220+
await auditPagerDutyNotification(env, { repoFullName: params.repoFullName, dedupKey: params.dedupKey }, "completed", PAGERDUTY_AUDIT_DETAIL_TRIGGERED, { source: resolution.source });
215221
} catch (error) {
216222
const message = errorMessage(error);
217223
console.warn(JSON.stringify({ event: "pagerduty_trigger_failed", repo: params.repoFullName, message: message.slice(0, 200) }));
@@ -251,7 +257,7 @@ export async function resolvePagerDutyIncident(
251257
signal: AbortSignal.timeout(5000),
252258
});
253259
if (!response.ok) throw new Error(`pagerduty_events_http_${response.status}`);
254-
await auditPagerDutyNotification(env, { repoFullName: params.repoFullName, dedupKey: params.dedupKey }, "completed", "resolved", { source: resolution.source });
260+
await auditPagerDutyNotification(env, { repoFullName: params.repoFullName, dedupKey: params.dedupKey }, "completed", PAGERDUTY_AUDIT_DETAIL_RESOLVED, { source: resolution.source });
255261
} catch (error) {
256262
const message = errorMessage(error);
257263
console.warn(JSON.stringify({ event: "pagerduty_resolve_failed", repo: params.repoFullName, message: message.slice(0, 200) }));

test/unit/notify-pagerduty.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,60 @@ describe("triggerPagerDutyIncident — cooldown gate (alert fatigue control #2)"
230230
expect(calls).toHaveLength(1);
231231
});
232232

233+
it("REGRESSION: a recent suppression (denied/cooldown_active) row does NOT itself suppress the next page (#9695)", async () => {
234+
const calls = stubFetch();
235+
const env = enabledEnv();
236+
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
237+
// Before #9695 this `denied` row was counted (the counter did not filter outcome/detail), so a single
238+
// suppression self-renewed the window and the repo could never page again.
239+
await recordAuditEvent(env, {
240+
eventType: "external_notification.pagerduty",
241+
actor: "loopover",
242+
targetKey: "ops_anomaly:acme/widgets",
243+
outcome: "denied",
244+
detail: "cooldown_active",
245+
metadata: {},
246+
createdAt: fiveMinutesAgo,
247+
});
248+
await trigger(env);
249+
expect(calls).toHaveLength(1); // it pages, because no ACTUAL page happened in the window
250+
});
251+
252+
it("REGRESSION: a recent auto-resolve (completed/resolved) row does NOT suppress the next page (#9695)", async () => {
253+
const calls = stubFetch();
254+
const env = enabledEnv();
255+
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
256+
// resolved shares outcome "completed" with a real page, so an outcome-only filter would wrongly count it.
257+
await recordAuditEvent(env, {
258+
eventType: "external_notification.pagerduty",
259+
actor: "loopover",
260+
targetKey: "ops_anomaly:acme/widgets",
261+
outcome: "completed",
262+
detail: "resolved",
263+
metadata: {},
264+
createdAt: fiveMinutesAgo,
265+
});
266+
await trigger(env);
267+
expect(calls).toHaveLength(1); // a re-page for a flapping condition is not blocked by its own resolve
268+
});
269+
270+
it("REGRESSION: a recent failed page (error) does NOT suppress its own retry (#9695)", async () => {
271+
const calls = stubFetch();
272+
const env = enabledEnv();
273+
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
274+
await recordAuditEvent(env, {
275+
eventType: "external_notification.pagerduty",
276+
actor: "loopover",
277+
targetKey: "ops_anomaly:acme/widgets",
278+
outcome: "error",
279+
detail: "The page failed",
280+
metadata: {},
281+
createdAt: fiveMinutesAgo,
282+
});
283+
await trigger(env);
284+
expect(calls).toHaveLength(1); // nobody was paged, so the retry must go through
285+
});
286+
233287
it("REGRESSION: a recent page recorded under the pre-rebrand 'loopover' actor still suppresses a duplicate within the cooldown window", async () => {
234288
const calls = stubFetch();
235289
const env = enabledEnv();

0 commit comments

Comments
 (0)