diff --git a/apps/dashboard/app/dashboard/alerts/AlertRuleEditor.tsx b/apps/dashboard/app/dashboard/alerts/AlertRuleEditor.tsx index c978799f..378667c5 100644 --- a/apps/dashboard/app/dashboard/alerts/AlertRuleEditor.tsx +++ b/apps/dashboard/app/dashboard/alerts/AlertRuleEditor.tsx @@ -167,7 +167,41 @@ export function AlertRuleEditor({ {condition.type === "NO_EVENTS" ? ( <> +
+ Watch +
+ + +
+
+ {condition.eventScope === "specific" ? ( + ) : null}

- Exact name as sent with trackEvent(); leave empty - for any event. Pick a window comfortably longer than the usual + {condition.eventScope === "specific" ? ( + <> + Exact name as sent with trackEvent().{" "} + + ) : ( + <>Alerts when no event of any name arrives. + )} + Pick a window comfortably longer than the usual gap between these events (max {MAX_NO_EVENTS_WINDOW_HOURS}h) — quiet apps can go hours without a signup. While the event stays missing the alert repeats every cooldown; there is no recovery @@ -414,6 +462,12 @@ export function AlertRuleEditor({ pending || !draft.name.trim() || draft.conditions.length === 0 || + draft.conditions.some( + (c) => + c.type === "NO_EVENTS" && + c.eventScope === "specific" && + !c.eventName.trim() + ) || draft.destinationIds.length === 0 } onClick={onSubmit} diff --git a/apps/dashboard/lib/alert-rules-heartbeat.test.ts b/apps/dashboard/lib/alert-rules-heartbeat.test.ts index 85ac549f..1ffbe02f 100644 --- a/apps/dashboard/lib/alert-rules-heartbeat.test.ts +++ b/apps/dashboard/lib/alert-rules-heartbeat.test.ts @@ -11,6 +11,7 @@ import { updateDraftCondition, validateAlertRuleDraft, type AlertRuleRow, + type AlertRuleFormDraft, } from "./alert-rules"; const baseRule = { @@ -99,11 +100,11 @@ describe("NO_EVENTS business heartbeats (dashboard)", () => { }); }); - it("omits eventName when left empty (any event)", () => { + it("omits eventName for the explicit all-events scope", () => { const result = validateAlertRuleDraft({ ...createDefaultAlertRuleDraft(), name: "Any", - conditions: [createEmptyNoEventsCondition({ windowHours: 2 })], + conditions: [createEmptyNoEventsCondition({ windowHours: 2, eventScope: "all" })], }); expect(result.ok && result.payload.conditions[0]).toEqual({ type: "NO_EVENTS", @@ -175,3 +176,98 @@ describe("NO_EVENTS business heartbeats (dashboard)", () => { ).toBe('no "signup_verified" event / 6h in production'); }); }); + +describe("NO_EVENTS event scope: all events vs a specific event", () => { + function draftWith( + cond: Parameters[0] + ): AlertRuleFormDraft { + return { + ...createDefaultAlertRuleDraft(), + name: "Heartbeat", + conditions: [createEmptyNoEventsCondition(cond)], + }; + } + + it("new heartbeat conditions default to a specific event", () => { + expect(createEmptyNoEventsCondition().eventScope).toBe("specific"); + }); + + it("'all events' sends no eventName, even if a name was typed earlier", () => { + for (const eventName of ["", "signup_verified"]) { + const result = validateAlertRuleDraft( + draftWith({ eventScope: "all", eventName }) + ); + expect(result.ok).toBe(true); + if (!result.ok) continue; + expect(result.payload.conditions[0]).not.toHaveProperty("eventName"); + } + }); + + it("'specific event' requires a non-empty name", () => { + for (const eventName of ["", " "]) { + const result = validateAlertRuleDraft( + draftWith({ eventScope: "specific", eventName }) + ); + expect(result.ok).toBe(false); + if (result.ok) continue; + expect(result.error).toMatch(/event name/i); + } + }); + + it("'specific event' sends the trimmed name", () => { + const result = validateAlertRuleDraft( + draftWith({ eventScope: "specific", eventName: " signup_verified " }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.payload.conditions[0]).toMatchObject({ + eventName: "signup_verified", + }); + }); + + it("existing rules without eventName open as 'all events' and round-trip unchanged", () => { + for (const extra of [{}, { eventName: null }]) { + const rule = { + ...baseRule, + conditions: [ + { type: "NO_EVENTS", windowMinutes: 360, environment: null, ...extra }, + ], + } as AlertRuleRow; + const draft = draftFromAlertRule(rule); + const cond = draft.conditions[0]!; + expect(cond.type).toBe("NO_EVENTS"); + if (cond.type !== "NO_EVENTS") continue; + expect(cond.eventScope).toBe("all"); + const result = validateAlertRuleDraft(draft); + expect(result.ok).toBe(true); + if (!result.ok) continue; + expect(result.payload.conditions[0]).not.toHaveProperty("eventName"); + } + }); + + it("rules with an eventName open as 'specific event'", () => { + const rule = { + ...baseRule, + conditions: [ + { + type: "NO_EVENTS", + windowMinutes: 360, + environment: null, + eventName: "signup_verified", + }, + ], + } as AlertRuleRow; + const cond = draftFromAlertRule(rule).conditions[0]!; + expect(cond).toMatchObject({ eventScope: "specific", eventName: "signup_verified" }); + }); + + it("switching scope keeps the typed name in the draft", () => { + let draft = draftWith({ eventScope: "specific", eventName: "signup_verified" }); + draft = updateDraftCondition(draft, 0, { eventScope: "all" }); + draft = updateDraftCondition(draft, 0, { eventScope: "specific" }); + expect(draft.conditions[0]).toMatchObject({ + eventScope: "specific", + eventName: "signup_verified", + }); + }); +}); diff --git a/apps/dashboard/lib/alert-rules.ts b/apps/dashboard/lib/alert-rules.ts index bddef08c..142a0690 100644 --- a/apps/dashboard/lib/alert-rules.ts +++ b/apps/dashboard/lib/alert-rules.ts @@ -118,11 +118,19 @@ export type ErrorCountConditionDraft = { environment: string; }; +/** Which events a NO_EVENTS condition watches. */ +export type NoEventsEventScope = "all" | "specific"; + /** "No `eventName` event for `windowHours` hours" (business heartbeat). */ export type NoEventsConditionDraft = { key: string; type: "NO_EVENTS"; - /** Empty = any event (original NO_EVENTS behaviour). */ + /** + * "all" = any event in the project (original NO_EVENTS behaviour, no + * `eventName` sent). "specific" = only events named `eventName` (required). + */ + eventScope: NoEventsEventScope; + /** Used only when `eventScope` is "specific"; kept while toggling scope. */ eventName: string; windowHours: number; environment: string; @@ -186,6 +194,11 @@ export function createEmptyNoEventsCondition( return { key: nextDraftKey(), type: "NO_EVENTS", + eventScope: + overrides?.eventScope ?? + (overrides?.eventName !== undefined && overrides.eventName.trim() === "" + ? "all" + : "specific"), eventName: overrides?.eventName ?? "", windowHours: overrides?.windowHours ?? DEFAULT_NO_EVENTS_WINDOW_HOURS, environment: overrides?.environment ?? "", @@ -274,6 +287,7 @@ export function updateDraftCondition( } return { ...c, + ...(patch.eventScope !== undefined ? { eventScope: patch.eventScope } : {}), ...(patch.eventName !== undefined ? { eventName: patch.eventName } : {}), ...(patch.windowHours !== undefined ? { windowHours: patch.windowHours } @@ -330,6 +344,7 @@ export function draftFromAlertRule(rule: AlertRuleRow): AlertRuleFormDraft { } else if (c.type === "NO_EVENTS" && noEventsConditionIsEditable(c)) { editableConditions.push( createEmptyNoEventsCondition({ + eventScope: c.eventName ? "specific" : "all", eventName: c.eventName ?? "", windowHours: c.windowMinutes / 60, environment: c.environment ?? "", @@ -510,7 +525,13 @@ export function validateAlertRuleDraft( error: `Condition #${i + 1}: window must be ${MIN_NO_EVENTS_WINDOW_HOURS}–${MAX_NO_EVENTS_WINDOW_HOURS} hours`, }; } - const eventName = c.eventName.trim(); + const eventName = c.eventScope === "specific" ? c.eventName.trim() : ""; + if (c.eventScope === "specific" && eventName.length === 0) { + return { + ok: false, + error: `Condition #${i + 1}: enter the event name, or choose "All events"`, + }; + } if (eventName.length > MAX_ALERT_RULE_EVENT_NAME_LENGTH) { return { ok: false,