Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions apps/dashboard/app/dashboard/alerts/AlertRuleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,41 @@ export function AlertRuleEditor({
</div>
{condition.type === "NO_EVENTS" ? (
<>
<fieldset className="mb-3 flex flex-col gap-1.5 text-[12px] text-muted-foreground">
<legend className="mb-1">Watch</legend>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-1.5 text-[13px] text-foreground">
<input
type="radio"
name={`condition-${condition.key}-event-scope`}
value="all"
checked={condition.eventScope === "all"}
disabled={pending}
onChange={() =>
updateCondition(index, { eventScope: "all" })
}
aria-label={`Condition ${index + 1} all events`}
/>
All events in the project
</label>
<label className="flex items-center gap-1.5 text-[13px] text-foreground">
<input
type="radio"
name={`condition-${condition.key}-event-scope`}
value="specific"
checked={condition.eventScope === "specific"}
disabled={pending}
onChange={() =>
updateCondition(index, { eventScope: "specific" })
}
aria-label={`Condition ${index + 1} specific event`}
/>
A specific event
</label>
</div>
</fieldset>
<div className="flex flex-wrap gap-3">
{condition.eventScope === "specific" ? (
<label className="flex min-w-[12rem] flex-1 flex-col gap-1 text-[12px] text-muted-foreground">
Event name
<input
Expand All @@ -181,8 +215,16 @@ export function AlertRuleEditor({
maxLength={MAX_ALERT_RULE_EVENT_NAME_LENGTH}
className={`${inputClass} font-mono`}
aria-label={`Condition ${index + 1} event name`}
aria-invalid={!condition.eventName.trim()}
required
/>
{!condition.eventName.trim() ? (
<span className="text-destructive" role="status">
Enter the event name, or choose &quot;All events&quot;.
</span>
) : null}
</label>
) : null}
<label className="flex flex-col gap-1 text-[12px] text-muted-foreground">
No event for (hours)
<input
Expand Down Expand Up @@ -219,8 +261,14 @@ export function AlertRuleEditor({
</label>
</div>
<p className="mt-2 text-[12px] text-muted-foreground">
Exact name as sent with <code>trackEvent()</code>; leave empty
for any event. Pick a window comfortably longer than the usual
{condition.eventScope === "specific" ? (
<>
Exact name as sent with <code>trackEvent()</code>.{" "}
</>
) : (
<>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
Expand Down Expand Up @@ -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}
Expand Down
100 changes: 98 additions & 2 deletions apps/dashboard/lib/alert-rules-heartbeat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
updateDraftCondition,
validateAlertRuleDraft,
type AlertRuleRow,
type AlertRuleFormDraft,
} from "./alert-rules";

const baseRule = {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<typeof createEmptyNoEventsCondition>[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",
});
});
});
25 changes: 23 additions & 2 deletions apps/dashboard/lib/alert-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ?? "",
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 ?? "",
Expand Down Expand Up @@ -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,
Expand Down
Loading