From 6cc52df36631bb79c040de5d7803c944f2910bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sat, 22 Aug 2026 18:54:57 +0200 Subject: [PATCH 1/2] feat(alerts): usage-limit and data-health notifications - 80% usage warning + 100% exceeded emails to org admins, triggered where the usage counter is computed (sessions job); dedupe markers reset on billing- cycle rollover and on limit raises via the Polar webhook. - In-app >=80% warning banner; exceeded banner copy now states that events are still collected and only chart display pauses. - New daily dataHealth cron: emails orgs whose project never received events (48h grace) or whose event flow stalled 7+ days. Uses a new getLastEventPerProject() reading distinct_event_names_mv (pre-aggregated, instance-wide in one query). Stall notices re-arm automatically when data resumes and stalls again. - Wire the notification-rule email channel: sendToEmail was a silent no-op in the worker even though the UI persisted the toggle; now sends a notification-rule email to org members and the Email integration is enabled in BASE_INTEGRATIONS. - New product_alerts email category (unsubscribe + prefs UI pick it up automatically); 5 new react-email templates. - Weekly digest MIN_EVENTS 5000 -> 100 so customers on smaller plans receive the digest too. Claude-Session: https://claude.ai/code/session_017resSrRFv7wxsc9ifsALAh --- .../api/src/controllers/webhook.controller.ts | 9 + .../start/src/routes/_app.$organizationId.tsx | 21 +- apps/worker/src/boot-cron.ts | 5 + apps/worker/src/boot-debug.ts | 1 + apps/worker/src/jobs/cron.data-health.ts | 187 ++++++++++++++++++ apps/worker/src/jobs/cron.ts | 4 + apps/worker/src/jobs/cron.weekly-digest.ts | 6 +- apps/worker/src/jobs/notification.ts | 30 +++ apps/worker/src/jobs/sessions.ts | 95 +++++++++ packages/constants/index.ts | 5 + .../migration.sql | 9 + packages/db/prisma/schema.prisma | 10 + .../db/src/services/notification.service.ts | 20 +- packages/db/src/services/project.service.ts | 25 ++- packages/email/src/emails/index.tsx | 43 ++++ .../email/src/emails/notification-rule.tsx | 51 +++++ .../src/emails/tracking-data-stopped.tsx | 74 +++++++ .../email/src/emails/tracking-no-data.tsx | 66 +++++++ .../email/src/emails/usage-limit-exceeded.tsx | 62 ++++++ .../email/src/emails/usage-near-limit.tsx | 62 ++++++ packages/queue/src/queues.ts | 7 +- 21 files changed, 778 insertions(+), 14 deletions(-) create mode 100644 apps/worker/src/jobs/cron.data-health.ts create mode 100644 packages/db/prisma/migrations/20260822110000_data_health_and_usage_alerts/migration.sql create mode 100644 packages/email/src/emails/notification-rule.tsx create mode 100644 packages/email/src/emails/tracking-data-stopped.tsx create mode 100644 packages/email/src/emails/tracking-no-data.tsx create mode 100644 packages/email/src/emails/usage-limit-exceeded.tsx create mode 100644 packages/email/src/emails/usage-near-limit.tsx 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}/${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..f3ab1feb9 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}/${project.organizationId}/${notification.projectId}`, + }, + }); + } return; } diff --git a/apps/worker/src/jobs/sessions.ts b/apps/worker/src/jobs/sessions.ts index d41303ae6..3dc52e1cd 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,99 @@ 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; + } + + 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}/${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, + }, + }); + } + } + + await db.organization.update({ + where: { id: organization.id }, + data: exceeded + ? // Mark the warning as sent too — crossing both thresholds between two + // runs must not queue a redundant warning after the exceeded notice. + { usageExceededSentAt: new Date(), usageWarningSentAt: new Date() } + : { usageWarningSentAt: new Date() }, + }); + + logger.info( + { + organizationId: organization.id, + count, + limit, + kind: exceeded ? 'exceeded' : 'near-limit', + recipients: recipients.size, + }, + 'Sent usage alert emails' + ); +} 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..4df6c3b0d 100644 --- a/packages/db/src/services/project.service.ts +++ b/packages/db/src/services/project.service.ts @@ -1,6 +1,10 @@ import { cacheable } from '@openpanel/redis'; import sqlstring from 'sqlstring'; -import { chQuery, TABLE_NAMES } from '../clickhouse/client'; +import { + chQuery, + convertClickhouseDateToJs, + TABLE_NAMES, +} from '../clickhouse/client'; import { ClientType, type Prisma, type Project } from '../prisma-client'; import { db } from '../prisma-client'; @@ -119,6 +123,25 @@ 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> => { + const res = await chQuery<{ project_id: string; last_event_at: string }>( + `SELECT project_id, max(created_at) as last_event_at FROM ${TABLE_NAMES.event_names_mv} GROUP BY project_id` + ); + return new Map( + res.map((row) => [ + row.project_id, + convertClickhouseDateToJs(row.last_event_at), + ]) + ); +}; + /** * Resolve and validate a projectId for an API client. * diff --git a/packages/email/src/emails/index.tsx b/packages/email/src/emails/index.tsx index cba787a80..fc4d9e972 100644 --- a/packages/email/src/emails/index.tsx +++ b/packages/email/src/emails/index.tsx @@ -15,10 +15,19 @@ import OnboardingTrialEnded, { import OnboardingTrialEnding, { zOnboardingTrialEnding, } from './onboarding-trial-ending'; +import NotificationRule, { zNotificationRule } from './notification-rule'; import OnboardingWelcome, { zOnboardingWelcome } from './onboarding-welcome'; import OnboardingWhatToTrack, { zOnboardingWhatToTrack, } from './onboarding-what-to-track'; +import TrackingDataStopped, { + zTrackingDataStopped, +} from './tracking-data-stopped'; +import TrackingNoData, { zTrackingNoData } from './tracking-no-data'; +import UsageLimitExceeded, { + zUsageLimitExceeded, +} from './usage-limit-exceeded'; +import UsageNearLimit, { zUsageNearLimit } from './usage-near-limit'; import WeeklyDigest, { zWeeklyDigest } from './weekly-digest'; export const templates = { @@ -80,6 +89,40 @@ export const templates = { schema: zWeeklyDigest, category: 'weekly_digest' as const, }, + 'usage-near-limit': { + subject: (data: z.infer) => + `You've used ${Math.round((data.eventsCount / data.eventsLimit) * 100)}% of your monthly events`, + Component: UsageNearLimit, + schema: zUsageNearLimit, + category: 'product_alerts' as const, + }, + 'usage-limit-exceeded': { + subject: () => 'Event limit reached — charts paused, data still collected', + Component: UsageLimitExceeded, + schema: zUsageLimitExceeded, + category: 'product_alerts' as const, + }, + 'tracking-no-data': { + subject: () => "Your tracking isn't sending data yet", + Component: TrackingNoData, + schema: zTrackingNoData, + category: 'product_alerts' as const, + }, + 'tracking-data-stopped': { + subject: (data: z.infer) => + data.projectNames.length === 1 + ? `${data.projectNames[0]} stopped sending events` + : 'Some of your projects stopped sending events', + Component: TrackingDataStopped, + schema: zTrackingDataStopped, + category: 'product_alerts' as const, + }, + 'notification-rule': { + subject: (data: z.infer) => data.title, + Component: NotificationRule, + schema: zNotificationRule, + category: 'product_alerts' as const, + }, } as const; export type Templates = typeof templates; diff --git a/packages/email/src/emails/notification-rule.tsx b/packages/email/src/emails/notification-rule.tsx new file mode 100644 index 000000000..d45de0eff --- /dev/null +++ b/packages/email/src/emails/notification-rule.tsx @@ -0,0 +1,51 @@ +import { Text } from '@react-email/components'; +import React from 'react'; +import { z } from 'zod'; +import { Button } from '../components/button'; +import { Layout } from '../components/layout'; +import { withUtm } from '../utm'; + +export const zNotificationRule = z.object({ + title: z.string(), + message: z.string(), + projectName: z.string().optional(), + dashboardUrl: z.string().optional(), +}); + +export type Props = z.infer; +export default NotificationRule; +export function NotificationRule({ + title, + message, + projectName, + dashboardUrl, + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { + return ( + + + 🔔 {title} + {projectName ? ` — ${projectName}` : ''} + + {message} + {dashboardUrl && ( + + + + )} + + You're receiving this because a notification rule you set up matched. + Manage rules under Notifications in your project. + + + ); +} + +NotificationRule.PreviewProps = { + title: 'Conversion: Sign up completed', + message: 'A user completed the sign-up funnel.', + projectName: 'My website', + dashboardUrl: 'https://dashboard.openpanel.dev/org-id/project-id', +}; diff --git a/packages/email/src/emails/tracking-data-stopped.tsx b/packages/email/src/emails/tracking-data-stopped.tsx new file mode 100644 index 000000000..6b8398b28 --- /dev/null +++ b/packages/email/src/emails/tracking-data-stopped.tsx @@ -0,0 +1,74 @@ +import { Link, Text } from '@react-email/components'; +import React from 'react'; +import { z } from 'zod'; +import { Button } from '../components/button'; +import { Layout } from '../components/layout'; +import { withUtm } from '../utm'; + +export const zTrackingDataStopped = z.object({ + firstName: z.string().optional(), + projectNames: z.array(z.string()).min(1), + lastEventDate: z.string().optional(), + dashboardUrl: z.string(), +}); + +export type Props = z.infer; +export default TrackingDataStopped; +export function TrackingDataStopped({ + firstName, + projectNames, + lastEventDate, + dashboardUrl = 'https://dashboard.openpanel.dev', + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { + const single = projectNames.length === 1; + const names = projectNames.join(', '); + return ( + + Hi{firstName ? ` ${firstName}` : ''}, + + {single + ? `Your project ${names} stopped` + : `Your projects (${names}) stopped`}{' '} + sending events + {lastEventDate + ? ` — the last one arrived on ${lastEventDate}` + : ' about a week ago'} + . + + + This usually isn't intentional: a deploy that dropped the tracking + snippet, a changed client ID, or a new domain that isn't in your allowed + origins. Worth a quick check so you don't end up with a gap in your + data. + + + + + + The{' '} + + install guide + {' '} + covers a re-install for every framework. If it stopped on purpose — no + action needed, and sorry for the noise. Otherwise, reply and I'll help + you debug it. + + Carl + + ); +} + +TrackingDataStopped.PreviewProps = { + firstName: 'Alex', + projectNames: ['My website'], + lastEventDate: 'August 14', + dashboardUrl: 'https://dashboard.openpanel.dev/org-id', +}; diff --git a/packages/email/src/emails/tracking-no-data.tsx b/packages/email/src/emails/tracking-no-data.tsx new file mode 100644 index 000000000..a92939317 --- /dev/null +++ b/packages/email/src/emails/tracking-no-data.tsx @@ -0,0 +1,66 @@ +import { Link, Text } from '@react-email/components'; +import React from 'react'; +import { z } from 'zod'; +import { Button } from '../components/button'; +import { Layout } from '../components/layout'; +import { withUtm } from '../utm'; + +export const zTrackingNoData = z.object({ + firstName: z.string().optional(), + projectNames: z.array(z.string()).min(1), + dashboardUrl: z.string(), +}); + +export type Props = z.infer; +export default TrackingNoData; +export function TrackingNoData({ + firstName, + projectNames, + dashboardUrl = 'https://dashboard.openpanel.dev', + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { + const single = projectNames.length === 1; + const names = projectNames.join(', '); + return ( + + Hi{firstName ? ` ${firstName}` : ''}, + + {single + ? `Your project ${names} hasn't` + : `Your projects (${names}) haven't`}{' '} + received any events yet — which usually means the tracking snippet isn't + installed, or something is blocking it. + + + The most common fixes: the snippet isn't on the page (or not deployed + yet), the client ID doesn't match, or the domain isn't in your allowed + origins. + + + + + + Framework-specific instructions are in the{' '} + + install guide + + . And if you've tried and it still doesn't work, reply to this email — + I'll personally help you get it running. + + Carl + + ); +} + +TrackingNoData.PreviewProps = { + firstName: 'Alex', + projectNames: ['My website'], + dashboardUrl: 'https://dashboard.openpanel.dev/org-id', +}; diff --git a/packages/email/src/emails/usage-limit-exceeded.tsx b/packages/email/src/emails/usage-limit-exceeded.tsx new file mode 100644 index 000000000..596ead383 --- /dev/null +++ b/packages/email/src/emails/usage-limit-exceeded.tsx @@ -0,0 +1,62 @@ +import { Text } from '@react-email/components'; +import React from 'react'; +import { z } from 'zod'; +import { Button } from '../components/button'; +import { Layout } from '../components/layout'; +import { withUtm } from '../utm'; + +export const zUsageLimitExceeded = z.object({ + firstName: z.string().optional(), + organizationName: z.string(), + billingUrl: z.string(), + eventsLimit: z.number(), +}); + +const formatNumber = (count: number) => + new Intl.NumberFormat('en-US').format(count); + +export type Props = z.infer; +export default UsageLimitExceeded; +export function UsageLimitExceeded({ + firstName, + organizationName, + billingUrl = 'https://dashboard.openpanel.dev', + eventsLimit, + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { + return ( + + Hi{firstName ? ` ${firstName}` : ''}, + + {organizationName} has reached its monthly limit of{' '} + {formatNumber(eventsLimit)} events. + + + Important: we're still collecting every incoming event, so nothing is + being lost. But your charts are paused at the moment you hit the limit — + new data won't show until you upgrade or your next billing cycle starts. + + + Upgrading takes a minute and unlocks everything collected in the + meantime. + + + + + + If this month was an outlier, you can also just wait for the cycle to + reset. And if you're stuck between plans, reply and I'll help. + + Carl + + ); +} + +UsageLimitExceeded.PreviewProps = { + firstName: 'Alex', + organizationName: 'Acme', + billingUrl: 'https://dashboard.openpanel.dev/org-id/billing', + eventsLimit: 5000, +}; diff --git a/packages/email/src/emails/usage-near-limit.tsx b/packages/email/src/emails/usage-near-limit.tsx new file mode 100644 index 000000000..cff14cad2 --- /dev/null +++ b/packages/email/src/emails/usage-near-limit.tsx @@ -0,0 +1,62 @@ +import { Text } from '@react-email/components'; +import React from 'react'; +import { z } from 'zod'; +import { Button } from '../components/button'; +import { Layout } from '../components/layout'; +import { withUtm } from '../utm'; + +export const zUsageNearLimit = z.object({ + firstName: z.string().optional(), + organizationName: z.string(), + billingUrl: z.string(), + eventsCount: z.number(), + eventsLimit: z.number(), +}); + +const formatNumber = (count: number) => + new Intl.NumberFormat('en-US').format(count); + +export type Props = z.infer; +export default UsageNearLimit; +export function UsageNearLimit({ + firstName, + organizationName, + billingUrl = 'https://dashboard.openpanel.dev', + eventsCount, + eventsLimit, + unsubscribeUrl, +}: Props & { unsubscribeUrl?: string }) { + const percent = Math.round((eventsCount / eventsLimit) * 100); + return ( + + Hi{firstName ? ` ${firstName}` : ''}, + + Heads up: {organizationName} has used {formatNumber(eventsCount)} of its{' '} + {formatNumber(eventsLimit)} monthly events ({percent}%). + + + If you go over the limit, we keep collecting every event — nothing is + lost — but your charts stop showing new data until you upgrade or the + next billing cycle starts. + + + + + + Questions about which plan fits? Reply to this email and I'll help you + pick. + + Carl + + ); +} + +UsageNearLimit.PreviewProps = { + firstName: 'Alex', + organizationName: 'Acme', + billingUrl: 'https://dashboard.openpanel.dev/org-id/billing', + eventsCount: 4200, + eventsLimit: 5000, +}; diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts index c3d8b2f1e..92aa19a37 100644 --- a/packages/queue/src/queues.ts +++ b/packages/queue/src/queues.ts @@ -160,6 +160,10 @@ export type CronQueuePayloadWeeklyDigest = { type: 'weeklyDigest'; payload: undefined; }; +export type CronQueuePayloadDataHealth = { + type: 'dataHealth'; + payload: undefined; +}; export type CronQueuePayload = | CronQueuePayloadSalt | CronQueuePayloadFlushEvents @@ -177,7 +181,8 @@ export type CronQueuePayload = | CronQueuePayloadSessionReaper | CronQueuePayloadSessionVacuum | CronQueuePayloadInsightCleanup - | CronQueuePayloadWeeklyDigest; + | CronQueuePayloadWeeklyDigest + | CronQueuePayloadDataHealth; export type CronQueueType = CronQueuePayload['type']; From 689ed0156671d81981d334d12303eab603b05a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sat, 22 Aug 2026 20:16:50 +0200 Subject: [PATCH 2/2] fix(alerts): address review feedback on usage alerts - Claim the usage alert atomically (conditional updateMany) before sending so concurrent session jobs for the same org can't double-send; the claim is released if delivery fails so the next usage update retries. - Fall back to https://dashboard.openpanel.dev when DASHBOARD_URL is unset in the usage, data-health, and notification-rule emails. - getLastEventPerProject now uses the shared clix query builder per the repo's ClickHouse guidelines. Claude-Session: https://claude.ai/code/session_017resSrRFv7wxsc9ifsALAh --- apps/worker/src/jobs/cron.data-health.ts | 2 +- apps/worker/src/jobs/notification.ts | 2 +- apps/worker/src/jobs/sessions.ts | 139 ++++++++++++-------- packages/db/src/services/project.service.ts | 13 +- 4 files changed, 96 insertions(+), 60 deletions(-) diff --git a/apps/worker/src/jobs/cron.data-health.ts b/apps/worker/src/jobs/cron.data-health.ts index 2ea70aba3..2dc9c885d 100644 --- a/apps/worker/src/jobs/cron.data-health.ts +++ b/apps/worker/src/jobs/cron.data-health.ts @@ -124,7 +124,7 @@ export async function dataHealthCronJob() { continue; } - const dashboardUrl = `${process.env.DASHBOARD_URL}/${alert.organizationId}`; + const dashboardUrl = `${process.env.DASHBOARD_URL ?? 'https://dashboard.openpanel.dev'}/${alert.organizationId}`; if (alert.noData.length > 0) { for (const [to, firstName] of recipients) { diff --git a/apps/worker/src/jobs/notification.ts b/apps/worker/src/jobs/notification.ts index f3ab1feb9..91a84c2f7 100644 --- a/apps/worker/src/jobs/notification.ts +++ b/apps/worker/src/jobs/notification.ts @@ -55,7 +55,7 @@ export async function notificationJob(job: Job) { title: notification.title, message: notification.message, projectName: project.name, - dashboardUrl: `${process.env.DASHBOARD_URL}/${project.organizationId}/${notification.projectId}`, + dashboardUrl: `${process.env.DASHBOARD_URL ?? 'https://dashboard.openpanel.dev'}/${project.organizationId}/${notification.projectId}`, }, }); } diff --git a/apps/worker/src/jobs/sessions.ts b/apps/worker/src/jobs/sessions.ts index 3dc52e1cd..546bac40a 100644 --- a/apps/worker/src/jobs/sessions.ts +++ b/apps/worker/src/jobs/sessions.ts @@ -122,68 +122,97 @@ async function sendUsageAlerts(organization: Organization, count: number) { count <= limit && !organization.usageWarningSentAt; - if (!exceeded && !nearLimit) { + if (!(exceeded || nearLimit)) { return; } - const admins = await db.member.findMany({ + // 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: { - organizationId: organization.id, - role: 'org:admin', - user: { deletedAt: null }, + id: organization.id, + ...(exceeded + ? { usageExceededSentAt: null } + : { usageWarningSentAt: null }), }, - include: { user: { select: { email: true, firstName: true } } }, + data: exceeded + ? { usageExceededSentAt: claimedAt, usageWarningSentAt: claimedAt } + : { usageWarningSentAt: claimedAt }, }); - - const billingUrl = `${process.env.DASHBOARD_URL}/${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, - }, - }); - } + if (claimed.count === 0) { + return; } - await db.organization.update({ - where: { id: organization.id }, - data: exceeded - ? // Mark the warning as sent too — crossing both thresholds between two - // runs must not queue a redundant warning after the exceeded notice. - { usageExceededSentAt: new Date(), usageWarningSentAt: new Date() } - : { usageWarningSentAt: new Date() }, - }); + try { + const admins = await db.member.findMany({ + where: { + organizationId: organization.id, + role: 'org:admin', + user: { deletedAt: null }, + }, + include: { user: { select: { email: true, firstName: true } } }, + }); - logger.info( - { - organizationId: organization.id, - count, - limit, - kind: exceeded ? 'exceeded' : 'near-limit', - recipients: recipients.size, - }, - 'Sent usage alert emails' - ); + 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/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts index 4df6c3b0d..e6dc45ac5 100644 --- a/packages/db/src/services/project.service.ts +++ b/packages/db/src/services/project.service.ts @@ -1,10 +1,12 @@ import { cacheable } from '@openpanel/redis'; import sqlstring from 'sqlstring'; 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'; @@ -131,9 +133,14 @@ export const getProjectEventsCount = async (projectId: string) => { * from the map. */ export const getLastEventPerProject = async (): Promise> => { - const res = await chQuery<{ project_id: string; last_event_at: string }>( - `SELECT project_id, max(created_at) as last_event_at FROM ${TABLE_NAMES.event_names_mv} GROUP BY project_id` - ); + const res = await clix(ch) + .select<{ project_id: string; last_event_at: string }>([ + 'project_id', + 'max(created_at) AS last_event_at', + ]) + .from(TABLE_NAMES.event_names_mv) + .groupBy(['project_id']) + .execute(); return new Map( res.map((row) => [ row.project_id,