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" ? (
<>
+
+ {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,