diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts
index 694003e51..db26cea86 100644
--- a/apps/api/src/controllers/webhook.controller.ts
+++ b/apps/api/src/controllers/webhook.controller.ts
@@ -317,6 +317,12 @@ async function syncSubscriptionToOrg(
organization.subscriptionPeriodEventsLimit < subscriptionPeriodEventsLimit
? null
: undefined,
+ // A raised limit re-arms the usage alerts for the new headroom.
+ ...(typeof subscriptionPeriodEventsLimit === 'number' &&
+ typeof organization.subscriptionPeriodEventsLimit === 'number' &&
+ organization.subscriptionPeriodEventsLimit < subscriptionPeriodEventsLimit
+ ? { usageWarningSentAt: null, usageExceededSentAt: null }
+ : {}),
};
const changes = diffOrganizationFields(
@@ -444,6 +450,9 @@ export async function polarWebhook(
data: {
subscriptionPeriodEventsCount: 0,
subscriptionPeriodEventsCountExceededAt: null,
+ // New cycle — the usage alerts may fire again.
+ usageWarningSentAt: null,
+ usageExceededSentAt: null,
},
});
diff --git a/apps/start/src/routes/_app.$organizationId.tsx b/apps/start/src/routes/_app.$organizationId.tsx
index 25d85e19d..e86ad5f62 100644
--- a/apps/start/src/routes/_app.$organizationId.tsx
+++ b/apps/start/src/routes/_app.$organizationId.tsx
@@ -162,12 +162,31 @@ function Component() {
)}
+ {organization.isActive &&
+ !organization.isExceeded &&
+ organization.subscriptionPeriodEventsLimit > 0 &&
+ organization.subscriptionPeriodEventsCount >=
+ organization.subscriptionPeriodEventsLimit * 0.8 && (
+
+
+ See plans
+
+
+ )}
{organization.subscriptionPeriodEventsCountExceededAt &&
organization.isActive &&
organization.isExceeded && (
();
+ for (const member of members) {
+ if (member.user?.email && !seen.has(member.user.email)) {
+ seen.set(member.user.email, member.user.firstName ?? undefined);
+ }
+ }
+ return seen;
+}
+
+/**
+ * Daily rescue emails for paying/trialing orgs whose tracking is broken:
+ * projects that never received an event (48h grace) and projects whose event
+ * flow stalled for 7+ days. A silently broken install reads as "the product
+ * stopped working" — a working install is the cheapest retention there is.
+ *
+ * Dedupe: `noDataNotifiedAt` is sent once per project; `dataStoppedNotifiedAt`
+ * is compared against the newest event, so data resuming and stalling again
+ * notifies again without any clearing step.
+ */
+export async function dataHealthCronJob() {
+ if (process.env.SELF_HOSTED === 'true') {
+ return;
+ }
+
+ const now = Date.now();
+ const lastEventByProject = await getLastEventPerProject();
+
+ // Prefilter on the raw status column (computed fields can't be used in
+ // `where`), then refine with the canonical subscription state below.
+ const projects = await db.project.findMany({
+ where: {
+ deleteAt: null,
+ organization: { subscriptionStatus: { in: ['active', 'trialing'] } },
+ },
+ select: {
+ id: true,
+ name: true,
+ createdAt: true,
+ organizationId: true,
+ noDataNotifiedAt: true,
+ dataStoppedNotifiedAt: true,
+ organization: { select: { id: true, subscriptionState: true } },
+ },
+ });
+
+ const byOrg = new Map();
+
+ for (const project of projects) {
+ const state = project.organization.subscriptionState;
+ if (state !== 'active' && state !== 'trialing') {
+ continue;
+ }
+
+ const lastEventAt = lastEventByProject.get(project.id);
+
+ if (!lastEventAt) {
+ // Never received an event. One notice per project, after the grace
+ // period. (The onboarding drip already nudges brand-new orgs, so this
+ // mainly catches additional projects and broken installs.)
+ const oldEnough = now - project.createdAt.getTime() > NO_DATA_AFTER_MS;
+ if (oldEnough && !project.noDataNotifiedAt) {
+ const entry = byOrg.get(project.organizationId) ?? {
+ organizationId: project.organizationId,
+ noData: [],
+ stalled: [],
+ };
+ entry.noData.push({ id: project.id, name: project.name });
+ byOrg.set(project.organizationId, entry);
+ }
+ continue;
+ }
+
+ const stalled = now - lastEventAt.getTime() > STALLED_AFTER_MS;
+ const alreadyNotifiedForThisStall =
+ project.dataStoppedNotifiedAt &&
+ project.dataStoppedNotifiedAt > lastEventAt;
+ if (stalled && !alreadyNotifiedForThisStall) {
+ const entry = byOrg.get(project.organizationId) ?? {
+ organizationId: project.organizationId,
+ noData: [],
+ stalled: [],
+ };
+ entry.stalled.push({
+ id: project.id,
+ name: project.name,
+ lastEventAt,
+ });
+ byOrg.set(project.organizationId, entry);
+ }
+ }
+
+ let emailsSent = 0;
+
+ for (const alert of byOrg.values()) {
+ try {
+ const recipients = await recipientsForOrg(alert.organizationId);
+ if (recipients.size === 0) {
+ continue;
+ }
+
+ const dashboardUrl = `${process.env.DASHBOARD_URL ?? 'https://dashboard.openpanel.dev'}/${alert.organizationId}`;
+
+ if (alert.noData.length > 0) {
+ for (const [to, firstName] of recipients) {
+ await sendEmail('tracking-no-data', {
+ to,
+ data: {
+ firstName,
+ projectNames: alert.noData.map((p) => p.name),
+ dashboardUrl,
+ },
+ });
+ emailsSent++;
+ }
+ await db.project.updateMany({
+ where: { id: { in: alert.noData.map((p) => p.id) } },
+ data: { noDataNotifiedAt: new Date() },
+ });
+ }
+
+ if (alert.stalled.length > 0) {
+ const newestLastEvent = alert.stalled
+ .map((p) => p.lastEventAt)
+ .sort((a, b) => b.getTime() - a.getTime())[0];
+ for (const [to, firstName] of recipients) {
+ await sendEmail('tracking-data-stopped', {
+ to,
+ data: {
+ firstName,
+ projectNames: alert.stalled.map((p) => p.name),
+ lastEventDate: newestLastEvent?.toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ }),
+ dashboardUrl,
+ },
+ });
+ emailsSent++;
+ }
+ await db.project.updateMany({
+ where: { id: { in: alert.stalled.map((p) => p.id) } },
+ data: { dataStoppedNotifiedAt: new Date() },
+ });
+ }
+ } catch (err) {
+ logger.error(
+ { err, organizationId: alert.organizationId },
+ 'Data-health alert failed'
+ );
+ }
+ }
+
+ logger.info(
+ {
+ projects: projects.length,
+ organizations: byOrg.size,
+ emailsSent,
+ },
+ 'Data-health check complete'
+ );
+}
diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts
index 008d2dbd5..b922ab670 100644
--- a/apps/worker/src/jobs/cron.ts
+++ b/apps/worker/src/jobs/cron.ts
@@ -9,6 +9,7 @@ import {
import type { CronQueuePayload } from '@openpanel/queue';
import type { Job } from 'bullmq';
import { cohortRefreshCronJob } from './cron.cohort-refresh';
+import { dataHealthCronJob } from './cron.data-health';
import { jobDelete } from './cron.delete';
import { insightCleanupCronJob } from './cron.insight-cleanup';
import { weeklyDigestCronJob } from './cron.weekly-digest';
@@ -75,5 +76,8 @@ export async function cronJob(job: Job) {
case 'weeklyDigest': {
return await weeklyDigestCronJob();
}
+ case 'dataHealth': {
+ return await dataHealthCronJob();
+ }
}
}
diff --git a/apps/worker/src/jobs/cron.weekly-digest.ts b/apps/worker/src/jobs/cron.weekly-digest.ts
index 6902bc4cd..6a9435fb2 100644
--- a/apps/worker/src/jobs/cron.weekly-digest.ts
+++ b/apps/worker/src/jobs/cron.weekly-digest.ts
@@ -6,7 +6,11 @@ import { logger as baseLogger } from '@/utils/logger';
const logger = baseLogger.child({ job: 'weekly-digest' });
const DAY_MS = 24 * 60 * 60 * 1000;
-const MIN_EVENTS = 5000;
+// Keep this low: the digest is our main "value without logging in" touchpoint,
+// and the old 5000 gate excluded customers on smaller plans — exactly the ones
+// who benefit most from the reminder. Zero-visitor weeks are still skipped per
+// send, so quiet projects don't get empty emails.
+const MIN_EVENTS = 100;
const MAX_INSIGHTS = 5;
type DigestData = EmailData<'weekly-digest'>;
diff --git a/apps/worker/src/jobs/notification.ts b/apps/worker/src/jobs/notification.ts
index 702fa93e3..91a84c2f7 100644
--- a/apps/worker/src/jobs/notification.ts
+++ b/apps/worker/src/jobs/notification.ts
@@ -1,6 +1,7 @@
import type { Job } from 'bullmq';
import { Prisma, db } from '@openpanel/db';
+import { sendEmail } from '@openpanel/email';
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
import { execute as executeJavaScriptTemplate } from '@openpanel/js-runtime';
@@ -29,6 +30,35 @@ export async function notificationJob(job: Job) {
}
if (notification.sendToEmail) {
+ const project = await db.project.findUniqueOrThrow({
+ where: { id: notification.projectId },
+ select: { name: true, organizationId: true },
+ });
+ const members = await db.member.findMany({
+ where: {
+ organizationId: project.organizationId,
+ user: { deletedAt: null },
+ },
+ include: { user: { select: { email: true } } },
+ });
+ const emails = new Set(
+ members.flatMap((member) =>
+ member.user?.email ? [member.user.email] : [],
+ ),
+ );
+ for (const to of emails) {
+ // Per-recipient unsubscribe (product_alerts category) is handled
+ // inside sendEmail.
+ await sendEmail('notification-rule', {
+ to,
+ data: {
+ title: notification.title,
+ message: notification.message,
+ projectName: project.name,
+ dashboardUrl: `${process.env.DASHBOARD_URL ?? 'https://dashboard.openpanel.dev'}/${project.organizationId}/${notification.projectId}`,
+ },
+ });
+ }
return;
}
diff --git a/apps/worker/src/jobs/sessions.ts b/apps/worker/src/jobs/sessions.ts
index d41303ae6..546bac40a 100644
--- a/apps/worker/src/jobs/sessions.ts
+++ b/apps/worker/src/jobs/sessions.ts
@@ -8,10 +8,13 @@ import {
getOrganizationBillingEventsCount,
getProjectEventsCount,
} from '@openpanel/db';
+import type { Organization } from '@openpanel/db';
+import { sendEmail } from '@openpanel/email';
import { cacheable } from '@openpanel/redis';
import { createSessionEnd } from './events.create-session-end';
const INT4_MAX = 2_147_483_647;
+const USAGE_WARNING_THRESHOLD = 0.8;
export async function sessionsJob(job: Job) {
const res = await createSessionEnd(job);
@@ -88,7 +91,128 @@ const updateEventsCount = cacheable(async function updateEventsCount(
: organization.subscriptionPeriodEventsCountExceededAt,
},
});
+
+ if (!isSelfHosted) {
+ try {
+ await sendUsageAlerts(organization, organizationEventsCount);
+ } catch (e) {
+ logger.error({ err: e }, 'Failed to send usage alert emails');
+ }
+ }
}
return true;
}, 60 * 60);
+
+/**
+ * One warning at 80% and one notice at 100% per billing cycle. The sent-at
+ * markers are cleared by the Polar webhook when a new cycle resets the usage
+ * counter (or the limit is raised), so each cycle can alert again.
+ */
+async function sendUsageAlerts(organization: Organization, count: number) {
+ const limit = organization.subscriptionPeriodEventsLimit;
+ if (!limit || limit <= 0) {
+ return;
+ }
+
+ const exceeded = count > limit && !organization.usageExceededSentAt;
+ const nearLimit =
+ !exceeded &&
+ count >= limit * USAGE_WARNING_THRESHOLD &&
+ count <= limit &&
+ !organization.usageWarningSentAt;
+
+ if (!(exceeded || nearLimit)) {
+ return;
+ }
+
+ // Claim the alert atomically BEFORE sending: session jobs for different
+ // projects of the same org can run concurrently, and both would otherwise
+ // read null markers and double-send. Marking the warning together with the
+ // exceeded notice keeps a both-thresholds-in-one-jump crossing from queueing
+ // a redundant warning afterwards. Rolled back if every send fails.
+ const claimedAt = new Date();
+ const claimed = await db.organization.updateMany({
+ where: {
+ id: organization.id,
+ ...(exceeded
+ ? { usageExceededSentAt: null }
+ : { usageWarningSentAt: null }),
+ },
+ data: exceeded
+ ? { usageExceededSentAt: claimedAt, usageWarningSentAt: claimedAt }
+ : { usageWarningSentAt: claimedAt },
+ });
+ if (claimed.count === 0) {
+ return;
+ }
+
+ try {
+ const admins = await db.member.findMany({
+ where: {
+ organizationId: organization.id,
+ role: 'org:admin',
+ user: { deletedAt: null },
+ },
+ include: { user: { select: { email: true, firstName: true } } },
+ });
+
+ const billingUrl = `${process.env.DASHBOARD_URL ?? 'https://dashboard.openpanel.dev'}/${organization.id}/billing`;
+ const recipients = new Map(
+ admins
+ .filter((member) => member.user?.email)
+ .map((member) => [
+ member.user!.email,
+ member.user!.firstName ?? undefined,
+ ])
+ );
+
+ for (const [email, firstName] of recipients) {
+ if (exceeded) {
+ await sendEmail('usage-limit-exceeded', {
+ to: email,
+ data: {
+ firstName,
+ organizationName: organization.name,
+ billingUrl,
+ eventsLimit: limit,
+ },
+ });
+ } else {
+ await sendEmail('usage-near-limit', {
+ to: email,
+ data: {
+ firstName,
+ organizationName: organization.name,
+ billingUrl,
+ eventsCount: count,
+ eventsLimit: limit,
+ },
+ });
+ }
+ }
+
+ logger.info(
+ {
+ organizationId: organization.id,
+ count,
+ limit,
+ kind: exceeded ? 'exceeded' : 'near-limit',
+ recipients: recipients.size,
+ },
+ 'Sent usage alert emails'
+ );
+ } catch (error) {
+ // Release the claim so the next usage update retries the alert.
+ await db.organization.updateMany({
+ where: { id: organization.id },
+ data: exceeded
+ ? {
+ usageExceededSentAt: null,
+ usageWarningSentAt: organization.usageWarningSentAt,
+ }
+ : { usageWarningSentAt: null },
+ });
+ throw error;
+ }
+}
diff --git a/packages/constants/index.ts b/packages/constants/index.ts
index 4a186c8d0..6f4324ebf 100644
--- a/packages/constants/index.ts
+++ b/packages/constants/index.ts
@@ -606,6 +606,11 @@ export const emailCategories = {
label: 'Weekly digest',
description: 'A weekly summary of your analytics with AI-surfaced insights',
},
+ product_alerts: {
+ label: 'Product alerts',
+ description:
+ 'Important notices about your projects: tracking stopped sending data, event limits, and alerts from your notification rules',
+ },
} as const;
export type EmailCategory = keyof typeof emailCategories;
diff --git a/packages/db/prisma/migrations/20260822110000_data_health_and_usage_alerts/migration.sql b/packages/db/prisma/migrations/20260822110000_data_health_and_usage_alerts/migration.sql
new file mode 100644
index 000000000..0650394f8
--- /dev/null
+++ b/packages/db/prisma/migrations/20260822110000_data_health_and_usage_alerts/migration.sql
@@ -0,0 +1,9 @@
+-- Usage-alert dedupe markers (cleared on billing-cycle reset / limit raise)
+-- and data-health notice markers for the dataHealth cron.
+ALTER TABLE "organizations"
+ ADD COLUMN "usageWarningSentAt" TIMESTAMP(3),
+ ADD COLUMN "usageExceededSentAt" TIMESTAMP(3);
+
+ALTER TABLE "projects"
+ ADD COLUMN "noDataNotifiedAt" TIMESTAMP(3),
+ ADD COLUMN "dataStoppedNotifiedAt" TIMESTAMP(3);
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index f310d2158..3da985e29 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -114,6 +114,10 @@ model Organization {
// subscriptionStartsAt (overwritten with each period start on renewal) this
// is stable, so it can measure tenure (e.g. the switch-to-yearly prompt).
subscriptionFirstStartedAt DateTime?
+ // Usage-alert dedupe markers — cleared when a new billing cycle resets the
+ // usage counter (or the limit is raised), so each cycle can warn once.
+ usageWarningSentAt DateTime?
+ usageExceededSentAt DateTime?
// When deleteAt > now(), the organization will be deleted
deleteAt DateTime?
@@ -250,6 +254,12 @@ model Project {
allowUnsafeRevenueTracking Boolean @default(false)
/// [IPrismaProjectFilters]
filters Json @default("[]")
+ // Data-health notice markers set by the dataHealth cron. `noDataNotifiedAt`:
+ // told the org this project never received events. `dataStoppedNotifiedAt`:
+ // told them the event flow stalled — compared against the last event time, so
+ // a resume followed by a new stall notifies again without clearing.
+ noDataNotifiedAt DateTime?
+ dataStoppedNotifiedAt DateTime?
clients Client[]
reports Report[]
diff --git a/packages/db/src/services/notification.service.ts b/packages/db/src/services/notification.service.ts
index 5b357151a..9348a4849 100644
--- a/packages/db/src/services/notification.service.ts
+++ b/packages/db/src/services/notification.service.ts
@@ -50,16 +50,16 @@ export const BASE_INTEGRATIONS: Integration[] = [
},
organizationId: '',
},
- // {
- // id: EMAIL_NOTIFICATION_INTEGRATION_ID,
- // name: 'Email',
- // createdAt: new Date(),
- // updatedAt: new Date(),
- // config: {
- // type: EMAIL_NOTIFICATION_INTEGRATION_ID,
- // },
- // organizationId: '',
- // },
+ {
+ id: EMAIL_NOTIFICATION_INTEGRATION_ID,
+ name: 'Email',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ config: {
+ type: EMAIL_NOTIFICATION_INTEGRATION_ID,
+ },
+ organizationId: '',
+ },
];
export const isBaseIntegration = (id: string) =>
diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts
index 1639ba1c4..e6dc45ac5 100644
--- a/packages/db/src/services/project.service.ts
+++ b/packages/db/src/services/project.service.ts
@@ -1,6 +1,12 @@
import { cacheable } from '@openpanel/redis';
import sqlstring from 'sqlstring';
-import { chQuery, TABLE_NAMES } from '../clickhouse/client';
+import {
+ ch,
+ chQuery,
+ convertClickhouseDateToJs,
+ TABLE_NAMES,
+} from '../clickhouse/client';
+import { clix } from '../clickhouse/query-builder';
import { ClientType, type Prisma, type Project } from '../prisma-client';
import { db } from '../prisma-client';
@@ -119,6 +125,30 @@ export const getProjectEventsCount = async (projectId: string) => {
return res[0]?.count;
};
+/**
+ * Newest event timestamp per project, for the whole instance in one query.
+ * Reads the same pre-aggregated MV as getProjectEventsCount (it stores
+ * max(created_at) per (project_id, name) block), so this scans thousands of
+ * rows instead of the raw events table. Projects with no events are absent
+ * from the map.
+ */
+export const getLastEventPerProject = async (): Promise