From 0c45ad3f2266a42759d4d2f70d0f7e7e09cd3af3 Mon Sep 17 00:00:00 2001
From: Sanja Malovic <48502315+unjica@users.noreply.github.com>
Date: Tue, 20 Oct 2026 10:32:43 +0200
Subject: [PATCH 1/5] docs: add XERJ Corpus Hub contributor badge to READMEs
(#840)
---
README.de.md | 1 +
README.es.md | 1 +
README.fr.md | 1 +
README.md | 1 +
4 files changed, 4 insertions(+)
diff --git a/README.de.md b/README.de.md
index 71573e7f..55d418c6 100644
--- a/README.de.md
+++ b/README.de.md
@@ -10,6 +10,7 @@

[](https://www.npmjs.com/package/@telemetry-tracker/core)
[](https://www.npmjs.com/package/@telemetry-tracker/core)
+[](https://hub.xerj.org/contributors/unjica.html)
Open-source Fehlerverfolgung, Produktanalyse, und Sitzungstelemetrie.
diff --git a/README.es.md b/README.es.md
index 164c7b4d..2d586445 100644
--- a/README.es.md
+++ b/README.es.md
@@ -10,6 +10,7 @@

[](https://www.npmjs.com/package/@telemetry-tracker/core)
[](https://www.npmjs.com/package/@telemetry-tracker/core)
+[](https://hub.xerj.org/contributors/unjica.html)
Seguimiento de errores, análisis de productos y telemetría de sesiones open-source.
diff --git a/README.fr.md b/README.fr.md
index a64b041d..a8856111 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -10,6 +10,7 @@

[](https://www.npmjs.com/package/@telemetry-tracker/core)
[](https://www.npmjs.com/package/@telemetry-tracker/core)
+[](https://hub.xerj.org/contributors/unjica.html)
Suivi d'erreurs open-source, analytics produit et télémétrie de session.
diff --git a/README.md b/README.md
index c098fe3e..ba709422 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@

[](https://www.npmjs.com/package/@telemetry-tracker/core)
[](https://www.npmjs.com/package/@telemetry-tracker/core)
+[](https://hub.xerj.org/contributors/unjica.html)
Open-source error tracking, product analytics, and session telemetry.
From 64c62a7bec2c53f01c3f7d86c90045af3e54a077 Mon Sep 17 00:00:00 2001
From: Sanja Malovic <48502315+unjica@users.noreply.github.com>
Date: Tue, 20 Oct 2026 13:45:14 +0200
Subject: [PATCH 2/5] fix(alerts): explicit all-events vs specific-event scope
for heartbeat rules (#844)
---
.../app/dashboard/alerts/AlertRuleEditor.tsx | 58 +++++++++-
.../lib/alert-rules-heartbeat.test.ts | 100 +++++++++++++++++-
apps/dashboard/lib/alert-rules.ts | 25 ++++-
3 files changed, 177 insertions(+), 6 deletions(-)
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,
From 9c3b87096822d44e11503eff71e1b3d52345d9e8 Mon Sep 17 00:00:00 2001
From: Sanja Malovic <48502315+unjica@users.noreply.github.com>
Date: Tue, 20 Oct 2026 15:43:21 +0200
Subject: [PATCH 3/5] feat(marketing): missing-event alerts landing page
(/features/missing-event-alerts) (#842)
* feat(marketing): add missing-event alerts (business heartbeats) landing page
* feat(marketing): list verified heartbeat alert channels, trim setup duplication
---------
Co-authored-by: Telemetry Tracker Bot
---
.../components/marketing/guides/GuideCta.tsx | 4 +-
apps/dashboard/app/docs/alerts/page.tsx | 5 +-
.../features/missing-event-alerts/page.tsx | 291 ++++++++++++++++++
apps/dashboard/app/llms.txt/route.ts | 1 +
apps/dashboard/lib/marketing-guides.test.ts | 1 +
apps/dashboard/lib/public-seo-paths.test.ts | 1 +
apps/dashboard/lib/public-seo-paths.ts | 4 +-
7 files changed, 303 insertions(+), 4 deletions(-)
create mode 100644 apps/dashboard/app/features/missing-event-alerts/page.tsx
diff --git a/apps/dashboard/app/components/marketing/guides/GuideCta.tsx b/apps/dashboard/app/components/marketing/guides/GuideCta.tsx
index 9a8fb828..74f1c805 100644
--- a/apps/dashboard/app/components/marketing/guides/GuideCta.tsx
+++ b/apps/dashboard/app/components/marketing/guides/GuideCta.tsx
@@ -6,11 +6,13 @@ export function GuideCta({
body = "No credit card. Create a project, install the SDK, and the first error shows up in Issues.",
secondaryHref = "/docs/hosted-cloud",
secondaryLabel = "Hosted cloud guide",
+ primaryLabel = "Start free",
}: {
heading?: string;
body?: string;
secondaryHref?: string;
secondaryLabel?: string;
+ primaryLabel?: string;
}) {
return (
@@ -22,7 +24,7 @@ export function GuideCta({
pendingLabel="Loading…"
className="inline-flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-transform hover:scale-[1.02]"
>
- Start free
+ {primaryLabel}