From 31a26b4b8743bc8e968b52becc6d9b25f4a90f93 Mon Sep 17 00:00:00 2001 From: Ofer Shapira Date: Tue, 17 Feb 2026 22:27:30 +0200 Subject: [PATCH 1/4] feat: replace SMTP with Resend, switch Slack to bot token, make thresholds opt-in - Replace nodemailer/SMTP with Resend SDK for email alerts (1 env var vs 6) - Switch Slack from webhook to bot token + chat.postMessage for richer control - Make static thresholds opt-in (default 0 = disabled), z-score and trends still active - Pass DASHBOARD_URL through cron to alert links - Update README: section title, config defaults, alerting docs - Remove unused sendSlackResolution function Co-authored-by: Cursor --- .cursor/rules/project-context.mdc | 2 +- .env.example | 12 ++-- README.md | 34 +++++----- package-lock.json | 94 +++++++++++++++++++++------- package.json | 3 +- src/app/api/cron/route.ts | 4 +- src/app/settings/settings-client.tsx | 17 ++++- src/lib/alerts/email.ts | 32 ++++------ src/lib/alerts/slack.ts | 77 +++++++++-------------- src/lib/anomaly/thresholds.ts | 10 ++- src/lib/types.ts | 6 +- 11 files changed, 168 insertions(+), 123 deletions(-) diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 84881bd..5334f4c 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -46,7 +46,7 @@ Single cron endpoint `POST /api/cron` does both: collect → detect → alert in | `src/lib/anomaly/zscore.ts` | Layer 2: statistical z-score vs team mean | | `src/lib/anomaly/trends.ts` | Layer 3: personal spikes, drift above P75, model shift | | `src/lib/incidents.ts` | Incident lifecycle: create, alert, acknowledge, resolve + MTTD/MTTI/MTTR | -| `src/lib/alerts/slack.ts` | Slack webhook with block-kit messages | +| `src/lib/alerts/slack.ts` | Slack bot token + chat.postMessage with block-kit messages | | `src/lib/alerts/email.ts` | SMTP email with HTML templates | | `src/lib/date-utils.ts` | Date formatting helpers (formatDateShort, formatDateTick, formatDateLabel) | | `src/lib/format-utils.ts` | Model name shortening (MODEL_MAP regex → short labels) | diff --git a/.env.example b/.env.example index 72cbc30..e4422a1 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,12 @@ CURSOR_ADMIN_API_KEY=key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -SLACK_WEBHOOK_URL= +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_CHANNEL_ID=C0123456789 -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USER= -SMTP_PASS= -SMTP_FROM=cursor-tracker@yourcompany.com +DASHBOARD_URL=http://localhost:3000 + +RESEND_API_KEY= +RESEND_FROM=Cursor Tracker ALERT_EMAIL_TO=team-lead@yourcompany.com CRON_SECRET=your-secret-for-cron-endpoint diff --git a/README.md b/README.md index 2076214..6ff1e52 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,7 @@

Cursor Usage Tracker

- AI spend monitoring, anomaly detection, and alerting for teams on Cursor Enterprise.
- Know who's burning through your budget before the invoice tells you. + Know who's burning through your AI budget before the invoice tells you.

@@ -21,7 +20,7 @@ --- -## Why This Exists +## AI Spend Is a Blind Spot Engineering costs used to be two things: headcount and cloud infrastructure. You had tools for both. Then AI coding assistants showed up, and suddenly there's a third cost center that nobody has good tooling for. @@ -69,7 +68,7 @@ Every alert includes who, what model, how much, and a link to their dashboard pa | Layer | Method | What it catches | | -------------- | ------------- | --------------------------------------------------------------------------- | -| **Thresholds** | Static limits | Spend > $50/cycle, > 500 requests/day, > 5M tokens/day | +| **Thresholds** | Static limits | Optional hard caps on spend, requests, or tokens (disabled by default) | | **Z-Score** | Statistical | User 2+ standard deviations above team mean | | **Trends** | Behavioral | Personal spikes, sustained drift above P75, model shift to expensive models | @@ -91,8 +90,8 @@ Anomaly Detected ──→ Alert Sent ──→ Acknowledged ──→ Resolved ### Rich Alerting -- **Slack**: Block Kit messages with severity, user, model, value vs threshold, and dashboard links -- **Email**: HTML-formatted alerts with the same context +- **Slack**: Block Kit messages via bot token (`chat.postMessage`) with severity, user, model, value vs threshold, and dashboard links +- **Email**: HTML-formatted alerts via [Resend](https://resend.com) (one API key, no SMTP config) ### Web Dashboard @@ -136,20 +135,19 @@ Edit `.env`: # Required CURSOR_ADMIN_API_KEY=your_admin_api_key -# Alerting (at least one recommended) -SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx +# Alerting — Slack (at least one alerting channel recommended) +SLACK_BOT_TOKEN=xoxb-your-bot-token # bot token with chat:write scope +SLACK_CHANNEL_ID=C0123456789 # channel to post alerts to + +# Dashboard URL (used in alert links) +DASHBOARD_URL=http://localhost:3000 # Optional -CURSOR_ANALYTICS_API_KEY=your_analytics_key # for Insights page (DAU, model breakdowns, MCP) CRON_SECRET=your_secret_here # protects the cron endpoint DASHBOARD_PASSWORD=your_password # optional basic auth for the dashboard -# Email alerts (optional) -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USER=you@gmail.com -SMTP_PASS=app_password -SMTP_FROM=cursor-tracker@yourcompany.com +# Email alerts via Resend (optional) +RESEND_API_KEY=re_xxxxxxxxxxxx ALERT_EMAIL_TO=team-lead@company.com ``` @@ -258,9 +256,9 @@ All detection thresholds are configurable via the Settings page or the API: | Setting | Default | What it does | | -------------------- | ------- | ------------------------------------------------- | -| Max spend per cycle | $200 | Alert when a user exceeds this in a billing cycle | -| Max requests per day | 200 | Alert on excessive daily request count | -| Max tokens per day | 5M | Alert on excessive daily token consumption | +| Max spend per cycle | 0 (off) | Alert when a user exceeds this in a billing cycle | +| Max requests per day | 0 (off) | Alert on excessive daily request count | +| Max tokens per day | 0 (off) | Alert on excessive daily token consumption | | Z-score multiplier | 2.5 | How many standard deviations above mean to flag | | Z-score window | 7 days | Historical window for statistical comparison | | Spike multiplier | 3.0x | Alert when today > N× user's personal average | diff --git a/package-lock.json b/package-lock.json index d82e8b8..058f5c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,10 @@ "@tailwindcss/postcss": "^4.1.18", "better-sqlite3": "^12.6.2", "next": "^16.1.6", - "nodemailer": "^8.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.7.0", + "resend": "^6.9.2", "tailwindcss": "^4.1.18" }, "devDependencies": { @@ -25,7 +25,6 @@ "@semantic-release/git": "^10.0.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.2.3", - "@types/nodemailer": "^7.0.10", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^9.39.2", @@ -2676,6 +2675,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3068,16 +3073,6 @@ "undici-types": "~7.16.0" } }, - "node_modules/@types/nodemailer": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.10.tgz", - "integrity": "sha512-tP+9WggTFN22Zxh0XFyst7239H0qwiRCogsk7v9aQS79sYAJY+WEbTHbNYcxUMaalHKmsNpxmoTe35hBEMMd6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -5187,6 +5182,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6858,15 +6859,6 @@ "node": ">=18" } }, - "node_modules/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/normalize-package-data": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", @@ -9420,6 +9412,12 @@ "node": ">=4" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -9794,6 +9792,27 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resend": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", + "integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -10624,6 +10643,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -10827,6 +10856,16 @@ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -11382,6 +11421,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/package.json b/package.json index b4b57e3..067f936 100644 --- a/package.json +++ b/package.json @@ -72,10 +72,10 @@ "@tailwindcss/postcss": "^4.1.18", "better-sqlite3": "^12.6.2", "next": "^16.1.6", - "nodemailer": "^8.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.7.0", + "resend": "^6.9.2", "tailwindcss": "^4.1.18" }, "devDependencies": { @@ -85,7 +85,6 @@ "@semantic-release/git": "^10.0.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.2.3", - "@types/nodemailer": "^7.0.10", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^9.39.2", diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index a423565..753de71 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -38,7 +38,9 @@ export async function POST(request: Request) { if (detectionResult.newAnomalies.length > 0) { const pairs = processNewAnomalies(detectionResult.newAnomalies); - const alertResult = await sendAlerts(pairs); + const alertResult = await sendAlerts(pairs, { + dashboardUrl: process.env.DASHBOARD_URL, + }); results.alerts = alertResult; } } catch (error) { diff --git a/src/app/settings/settings-client.tsx b/src/app/settings/settings-client.tsx index c1484a4..b446343 100644 --- a/src/app/settings/settings-client.tsx +++ b/src/app/settings/settings-client.tsx @@ -61,7 +61,7 @@ export function SettingsClient({ config: initial }: SettingsClientProps) {

-
+
0 + ? `$${(config.thresholds.maxSpendCentsPerCycle / 100).toFixed(0)}` + : undefined + } + hint={config.thresholds.maxSpendCentsPerCycle === 0 ? "disabled" : undefined} unit="cents" /> 0 + ? `${(config.thresholds.maxTokensPerDay / 1_000_000).toFixed(1)}M` + : undefined + } + hint={config.thresholds.maxTokensPerDay === 0 ? "disabled" : undefined} unit="tokens" />
diff --git a/src/lib/alerts/email.ts b/src/lib/alerts/email.ts index fdcd854..c67572c 100644 --- a/src/lib/alerts/email.ts +++ b/src/lib/alerts/email.ts @@ -1,20 +1,10 @@ -import nodemailer from "nodemailer"; +import { Resend } from "resend"; import type { Anomaly, Incident } from "../types"; -function getTransporter() { - const host = process.env.SMTP_HOST; - const port = parseInt(process.env.SMTP_PORT ?? "587", 10); - const user = process.env.SMTP_USER; - const pass = process.env.SMTP_PASS; - - if (!host || !user || !pass) return null; - - return nodemailer.createTransport({ - host, - port, - secure: port === 465, - auth: { user, pass }, - }); +function getClient(): Resend | null { + const key = process.env.RESEND_API_KEY; + if (!key) return null; + return new Resend(key); } function formatValue(metric: string, value: number): string { @@ -69,22 +59,26 @@ export async function sendEmailAlert( incident: Incident, options: { to?: string; dashboardUrl?: string } = {}, ): Promise { - const transporter = getTransporter(); - if (!transporter) return false; + const resend = getClient(); + if (!resend) return false; const to = options.to ?? process.env.ALERT_EMAIL_TO; if (!to) return false; - const from = process.env.SMTP_FROM ?? "cursor-tracker@noreply.com"; + const from = process.env.RESEND_FROM ?? "Cursor Tracker "; const severityPrefix = anomaly.severity === "critical" ? "[CRITICAL]" : "[WARNING]"; try { - await transporter.sendMail({ + const { error } = await resend.emails.send({ from, to, subject: `${severityPrefix} Cursor Usage Alert: ${anomaly.userEmail} — ${anomaly.metric}`, html: buildHtml(anomaly, incident, options.dashboardUrl), }); + if (error) { + console.error("[email] Resend error:", error.message); + return false; + } return true; } catch { console.error("[email] Failed to send alert email"); diff --git a/src/lib/alerts/slack.ts b/src/lib/alerts/slack.ts index aa91bc7..cbfdbe6 100644 --- a/src/lib/alerts/slack.ts +++ b/src/lib/alerts/slack.ts @@ -1,5 +1,7 @@ import type { Anomaly, Incident } from "../types"; +const SLACK_API_URL = "https://slack.com/api/chat.postMessage"; + interface SlackBlock { type: string; text?: { type: string; text: string; emoji?: boolean }; @@ -11,6 +13,21 @@ function severityEmoji(severity: string): string { return severity === "critical" ? ":rotating_light:" : ":warning:"; } +function formatValue(metric: string, value: number): string { + switch (metric) { + case "spend": + return `$${(value / 100).toFixed(2)}`; + case "tokens": + return `${(value / 1_000_000).toFixed(2)}M`; + case "requests": + return `${value.toFixed(0)}`; + case "model_shift": + return `${value.toFixed(0)}%`; + default: + return `${value}`; + } +} + function buildAlertBlocks( anomaly: Anomaly, incident: Incident, @@ -87,66 +104,32 @@ function buildAlertBlocks( return blocks; } -function formatValue(metric: string, value: number): string { - switch (metric) { - case "spend": - return `$${(value / 100).toFixed(2)}`; - case "tokens": - return `${(value / 1_000_000).toFixed(2)}M`; - case "requests": - return `${value.toFixed(0)}`; - case "model_shift": - return `${value.toFixed(0)}%`; - default: - return `${value}`; - } -} - export async function sendSlackAlert( anomaly: Anomaly, incident: Incident, - options: { webhookUrl?: string; dashboardUrl?: string } = {}, + options: { dashboardUrl?: string } = {}, ): Promise { - const webhookUrl = options.webhookUrl ?? process.env.SLACK_WEBHOOK_URL; - if (!webhookUrl) return false; + const token = process.env.SLACK_BOT_TOKEN; + const channel = process.env.SLACK_CHANNEL_ID; + if (!token || !channel) return false; const blocks = buildAlertBlocks(anomaly, incident, options.dashboardUrl); - const response = await fetch(webhookUrl, { + const response = await fetch(SLACK_API_URL, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json; charset=utf-8", + Authorization: `Bearer ${token}`, + }, body: JSON.stringify({ + channel, text: `${severityEmoji(anomaly.severity)} ${anomaly.message} — ${anomaly.userEmail}`, blocks, }), }); - return response.ok; -} - -export async function sendSlackResolution( - anomaly: Anomaly, - options: { webhookUrl?: string } = {}, -): Promise { - const webhookUrl = options.webhookUrl ?? process.env.SLACK_WEBHOOK_URL; - if (!webhookUrl) return false; - - const response = await fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - text: `:white_check_mark: Resolved: ${anomaly.message} — ${anomaly.userEmail}`, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `:white_check_mark: *Resolved:* ${anomaly.message}\n*User:* ${anomaly.userEmail}`, - }, - }, - ], - }), - }); + if (!response.ok) return false; - return response.ok; + const data = (await response.json()) as { ok: boolean }; + return data.ok; } diff --git a/src/lib/anomaly/thresholds.ts b/src/lib/anomaly/thresholds.ts index c47bf39..1b50b3b 100644 --- a/src/lib/anomaly/thresholds.ts +++ b/src/lib/anomaly/thresholds.ts @@ -20,7 +20,10 @@ export function detectThresholdAnomalies(config: DetectionConfig): Anomaly[] { }>; for (const s of spenders) { - if (s.spend_cents > config.thresholds.maxSpendCentsPerCycle) { + if ( + config.thresholds.maxSpendCentsPerCycle > 0 && + s.spend_cents > config.thresholds.maxSpendCentsPerCycle + ) { anomalies.push({ userEmail: s.email, type: "threshold", @@ -55,7 +58,10 @@ export function detectThresholdAnomalies(config: DetectionConfig): Anomaly[] { }>; for (const r of dailyRequests) { - if (r.agent_requests > config.thresholds.maxRequestsPerDay) { + if ( + config.thresholds.maxRequestsPerDay > 0 && + r.agent_requests > config.thresholds.maxRequestsPerDay + ) { anomalies.push({ userEmail: r.email, type: "threshold", diff --git a/src/lib/types.ts b/src/lib/types.ts index 09b290a..3bca1fa 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -384,9 +384,9 @@ export interface DetectionConfig { export const DEFAULT_CONFIG: DetectionConfig = { thresholds: { - maxSpendCentsPerCycle: 20000, - maxRequestsPerDay: 200, - maxTokensPerDay: 5_000_000, + maxSpendCentsPerCycle: 0, + maxRequestsPerDay: 0, + maxTokensPerDay: 0, }, zscore: { multiplier: 2.5, From 685eb28fc2728b08654456e163b91191ed8289e6 Mon Sep 17 00:00:00 2001 From: Ofer Shapira Date: Tue, 17 Feb 2026 22:44:30 +0200 Subject: [PATCH 2/4] feat: add mock database for safe demos and screenshots - Add scripts/generate-mock-db.ts: generates data/mock.db with 65 fake users, 30 days of activity, anomalies, billing groups, and analytics - Support DATABASE_PATH env var in db.ts for switching databases - Add npm run dev:mock and Dev Server (Mock Data) VS Code task - Commit mock.db (776KB) so cloners get demo data out of the box Co-authored-by: Cursor --- .gitignore | 1 + .vscode/tasks.json | 19 + data/mock.db | Bin 0 -> 4096 bytes package.json | 4 +- scripts/generate-mock-db.ts | 771 ++++++++++++++++++++++++++++++++++++ src/lib/db.ts | 2 +- 6 files changed, 795 insertions(+), 2 deletions(-) create mode 100644 data/mock.db create mode 100644 scripts/generate-mock-db.ts diff --git a/.gitignore b/.gitignore index 6bdb6b2..3831596 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .next/ dist/ data/*.db +!data/mock.db data/*.db-journal data/*.db-shm data/*.db-wal diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3701e61..1284536 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -20,6 +20,25 @@ "color": "terminal.ansiCyan" } }, + { + "label": "Dev Server (Mock Data)", + "type": "shell", + "command": "npm run dev:mock -- --turbopack -p 3456", + "problemMatcher": [], + "isBackground": true, + "presentation": { + "group": "cursor-tracker", + "panel": "dedicated", + "showReuseMessage": false, + "clear": true, + "reveal": "always", + "focus": false + }, + "icon": { + "id": "globe", + "color": "terminal.ansiYellow" + } + }, { "label": "Collect Data", "type": "shell", diff --git a/data/mock.db b/data/mock.db new file mode 100644 index 0000000000000000000000000000000000000000..159e90adb13561c0365c22e76084c658383a62f1 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYFvV#S79dK(-m98b?E5 nGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nC=3Arqf-Z% literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 067f936..0551e0a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "format": "prettier --write .", "prepare": "husky", "collect": "tsx src/lib/cli/collect.ts", - "detect": "tsx src/lib/cli/detect.ts" + "detect": "tsx src/lib/cli/detect.ts", + "generate:mock": "tsx scripts/generate-mock-db.ts", + "dev:mock": "DATABASE_PATH=data/mock.db next dev" }, "lint-staged": { "*.{ts,tsx,js,jsx}": [ diff --git a/scripts/generate-mock-db.ts b/scripts/generate-mock-db.ts new file mode 100644 index 0000000..71a8382 --- /dev/null +++ b/scripts/generate-mock-db.ts @@ -0,0 +1,771 @@ +import Database from "better-sqlite3"; +import { unlinkSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DB_PATH = path.join(__dirname, "..", "data", "mock.db"); + +const TEAM_SIZE = 65; +const DAYS = 30; +const CYCLE_START = "2026-02-01"; +const CYCLE_END = "2026-03-01"; + +const FIRST_NAMES = [ + "Alex", + "Jordan", + "Morgan", + "Taylor", + "Casey", + "Riley", + "Quinn", + "Avery", + "Cameron", + "Drew", + "Emery", + "Finley", + "Harper", + "Hayden", + "Jamie", + "Kendall", + "Lane", + "Logan", + "Micah", + "Noel", + "Parker", + "Peyton", + "Reese", + "Robin", + "Sage", + "Sam", + "Shay", + "Skyler", + "Tatum", + "Val", + "Blake", + "Charlie", + "Dakota", + "Eden", + "Frankie", + "Gray", + "Harley", + "Indigo", + "Jesse", + "Kit", + "Lee", + "Marley", + "Nico", + "Oakley", + "Pat", + "Remy", + "River", + "Rowan", + "Scout", + "Sloane", + "Stevie", + "Toni", + "Uri", + "Wren", + "Yael", + "Zion", + "Ari", + "Bay", + "Coby", + "Dana", + "Ellis", + "Flynn", + "Gale", + "Holly", + "Ira", +]; + +const LAST_NAMES = [ + "Chen", + "Kim", + "Park", + "Singh", + "Patel", + "Cohen", + "Muller", + "Santos", + "Tanaka", + "Ivanov", + "Novak", + "Berg", + "Silva", + "Costa", + "Russo", + "Larsen", + "Holm", + "Varga", + "Kato", + "Lin", + "Wu", + "Zhang", + "Lee", + "Wang", + "Liu", + "Yang", + "Huang", + "Zhao", + "Zhou", + "Xu", + "Sun", + "Ma", + "Zhu", + "Hu", + "Guo", + "He", + "Luo", + "Gao", + "Liang", + "Zheng", + "Xie", + "Han", + "Tang", + "Feng", + "Yu", + "Dong", + "Xiao", + "Cheng", + "Cao", + "Yuan", + "Deng", + "Xu", + "Fu", + "Shen", + "Zeng", + "Peng", + "Lu", + "Su", + "Jiang", + "Cai", + "Wei", + "Ye", + "Pan", + "Du", + "Dai", + "Ren", +]; + +const MODELS = [ + "claude-4.5-sonnet", + "claude-4.6-opus-high", + "claude-4.6-opus-max", + "claude-4.5-haiku", + "claude-4.6-opus-high-thinking", + "gpt-5.2", + "claude-4.5-opus-high-thinking", + "gpt-5.2-codex", + "gemini-3-pro-preview", + "gemini-3-flash-preview", + "gpt-5.3-codex", + "claude-4.5-sonnet-thinking", +]; + +const MODEL_WEIGHTS = [20, 15, 8, 12, 10, 10, 5, 5, 5, 5, 3, 2]; + +const GROUPS = [ + { name: "Platform > Core", members: 12 }, + { name: "Platform > Infrastructure", members: 8 }, + { name: "Frontend > Web App", members: 10 }, + { name: "Frontend > Mobile", members: 6 }, + { name: "Backend > API", members: 9 }, + { name: "Data > Analytics", members: 5 }, + { name: "Data > ML", members: 4 }, + { name: "DevOps > SRE", members: 5 }, + { name: "QA > Automation", members: 3 }, + { name: "Security > AppSec", members: 3 }, +]; + +const EXTENSIONS = ["ts", "tsx", "js", "jsx", "css", "json", "md", "py", "yaml", "sql"]; + +const MCP_TOOLS = [ + { tool: "browser_navigate", server: "cursor-ide-browser" }, + { tool: "browser_snapshot", server: "cursor-ide-browser" }, + { tool: "browser_click", server: "cursor-ide-browser" }, + { tool: "get_design_context", server: "Figma" }, + { tool: "get_metadata", server: "Figma" }, + { tool: "query", server: "postgres-mcp" }, + { tool: "describe", server: "postgres-mcp" }, + { tool: "slack_post_message", server: "slack-mcp" }, + { tool: "slack_get_channel_history", server: "slack-mcp" }, + { tool: "search_docs", server: "sentry-mcp" }, + { tool: "list_issues", server: "sentry-mcp" }, + { tool: "resolve-library-id", server: "context7" }, + { tool: "query-docs", server: "context7" }, +]; + +const CLIENT_VERSIONS = [ + "2.2.36", + "2.2.43", + "2.2.44", + "2.3.21", + "2.3.29", + "2.3.34", + "2.3.35", + "2.3.41", +]; + +function rand(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)] as T; +} + +function weightedPick(items: string[], weights: number[]): string { + const total = weights.reduce((a, b) => a + b, 0); + let r = Math.random() * total; + for (let i = 0; i < items.length; i++) { + r -= weights[i] ?? 0; + if (r <= 0) return items[i] as string; + } + return items[items.length - 1] as string; +} + +function dateStr(daysAgo: number): string { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + return d.toISOString().slice(0, 10); +} + +function isoNow(): string { + return new Date().toISOString().replace("T", " ").slice(0, 19); +} + +function generateMembers(): Array<{ email: string; name: string; role: string; userId: string }> { + const members: Array<{ email: string; name: string; role: string; userId: string }> = []; + const usedNames = new Set(); + + for (let i = 0; i < TEAM_SIZE; i++) { + let first: string, last: string, fullName: string; + do { + first = pick(FIRST_NAMES); + last = pick(LAST_NAMES); + fullName = `${first} ${last}`; + } while (usedNames.has(fullName)); + usedNames.add(fullName); + + const email = `${first.toLowerCase()}.${last.toLowerCase()}@acme-corp.com`; + const role = i < 3 ? "owner" : "member"; + const userId = `usr_${Math.random().toString(36).slice(2, 14)}`; + members.push({ email, name: fullName, role, userId }); + } + return members; +} + +function run() { + try { + unlinkSync(DB_PATH); + } catch { + /* file may not exist */ + } + + const db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + + createSchema(db); + + const members = generateMembers(); + const now = isoNow(); + + const memberStmt = db.prepare( + "INSERT INTO members (email, user_id, name, role, is_removed, first_seen, last_seen) VALUES (?, ?, ?, ?, 0, ?, ?)", + ); + const memberTx = db.transaction(() => { + for (const m of members) memberStmt.run(m.email, m.userId, m.name, m.role, now, now); + }); + memberTx(); + + const userProfiles = members.map((m) => ({ + ...m, + primaryModel: weightedPick(MODELS, MODEL_WEIGHTS), + activityLevel: Math.random() < 0.15 ? "high" : Math.random() < 0.4 ? "medium" : "low", + clientVersion: pick(CLIENT_VERSIONS), + })); + + const dailyStmt = db.prepare( + `INSERT INTO daily_usage (date, email, user_id, is_active, lines_added, lines_deleted, + accepted_lines_added, accepted_lines_deleted, total_applies, total_accepts, total_rejects, + total_tabs_shown, tabs_accepted, composer_requests, chat_requests, agent_requests, + usage_based_reqs, most_used_model, tab_most_used_extension, client_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + + const spendStmt = db.prepare( + `INSERT INTO daily_spend (date, email, spend_cents, cycle_start) VALUES (?, ?, ?, ?)`, + ); + + const dailyTx = db.transaction(() => { + for (let d = 0; d < DAYS; d++) { + const date = dateStr(DAYS - 1 - d); + const isWeekend = new Date(date).getDay() % 6 === 0; + + for (const user of userProfiles) { + const activeChance = isWeekend + ? 0.15 + : user.activityLevel === "high" + ? 0.95 + : user.activityLevel === "medium" + ? 0.8 + : 0.6; + const isActive = Math.random() < activeChance; + if (!isActive) continue; + + const baseReqs = + user.activityLevel === "high" + ? rand(80, 250) + : user.activityLevel === "medium" + ? rand(20, 80) + : rand(3, 30); + const agentReqs = baseReqs; + const chatReqs = rand(0, Math.floor(agentReqs * 0.3)); + const composerReqs = rand(0, Math.floor(agentReqs * 0.2)); + const linesAdded = agentReqs * rand(3, 15); + const linesDeleted = Math.floor(linesAdded * (0.2 + Math.random() * 0.3)); + const acceptedLines = Math.floor(linesAdded * (0.4 + Math.random() * 0.4)); + const acceptedDeleted = Math.floor(linesDeleted * (0.3 + Math.random() * 0.3)); + const totalApplies = Math.floor(agentReqs * (0.5 + Math.random() * 0.3)); + const totalAccepts = Math.floor(totalApplies * (0.6 + Math.random() * 0.3)); + const totalRejects = totalApplies - totalAccepts; + const tabsShown = rand(20, 200); + const tabsAccepted = Math.floor(tabsShown * (0.3 + Math.random() * 0.4)); + const usageBasedReqs = Math.floor(agentReqs * (0.1 + Math.random() * 0.3)); + + const model = Math.random() < 0.8 ? user.primaryModel : weightedPick(MODELS, MODEL_WEIGHTS); + + dailyStmt.run( + date, + user.email, + user.userId, + 1, + linesAdded, + linesDeleted, + acceptedLines, + acceptedDeleted, + totalApplies, + totalAccepts, + totalRejects, + tabsShown, + tabsAccepted, + composerReqs, + chatReqs, + agentReqs, + usageBasedReqs, + model, + pick(EXTENSIONS), + user.clientVersion, + ); + + const spendCents = Math.floor(agentReqs * (1.5 + Math.random() * 4)); + spendStmt.run(date, user.email, spendCents, CYCLE_START); + } + } + }); + dailyTx(); + + const spendingStmt = db.prepare( + `INSERT INTO spending (email, user_id, name, cycle_start, spend_cents, included_spend_cents, + fast_premium_requests, monthly_limit_dollars, hard_limit_override_dollars) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + const spendingTx = db.transaction(() => { + for (const user of userProfiles) { + const totalSpend = + user.activityLevel === "high" + ? rand(15000, 80000) + : user.activityLevel === "medium" + ? rand(3000, 20000) + : rand(200, 5000); + const included = Math.floor(totalSpend * 0.3); + const premiumReqs = Math.floor(totalSpend / 15); + spendingStmt.run( + user.email, + user.userId, + user.name, + CYCLE_START, + totalSpend, + included, + premiumReqs, + null, + 0, + ); + } + }); + spendingTx(); + + let groupMemberIdx = 0; + const groupStmt = db.prepare( + "INSERT INTO billing_groups (id, name, member_count, spend_cents) VALUES (?, ?, ?, ?)", + ); + const gmStmt = db.prepare( + "INSERT INTO group_members (group_id, email, joined_at) VALUES (?, ?, ?)", + ); + const groupTx = db.transaction(() => { + for (const g of GROUPS) { + const id = `grp_${Math.random().toString(36).slice(2, 10)}`; + const groupMembers = userProfiles.slice(groupMemberIdx, groupMemberIdx + g.members); + groupMemberIdx += g.members; + const totalSpend = rand(10000, 200000); + groupStmt.run(id, g.name, g.members, totalSpend); + for (const m of groupMembers) { + gmStmt.run(id, m.email, now); + } + } + }); + groupTx(); + + const anomalyStmt = db.prepare( + `INSERT INTO anomalies (user_email, type, severity, metric, value, threshold, message, detected_at, resolved_at, alerted_at, diagnosis_model, diagnosis_kind, diagnosis_delta) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + const incidentStmt = db.prepare( + `INSERT INTO incidents (anomaly_id, user_email, status, detected_at, alerted_at, acknowledged_at, resolved_at, mttd_minutes, mtti_minutes, mttr_minutes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + const anomalyTx = db.transaction(() => { + const highSpenders = userProfiles.filter((u) => u.activityLevel === "high"); + for (let i = 0; i < 15; i++) { + const user = pick(highSpenders.length > 0 ? highSpenders : userProfiles); + const daysAgo = rand(0, 20); + const detectedAt = `${dateStr(daysAgo)} ${rand(8, 18)}:${String(rand(0, 59)).padStart(2, "0")}:00`; + const isResolved = daysAgo > 5 && Math.random() < 0.6; + const resolvedAt = isResolved + ? `${dateStr(daysAgo - rand(0, 2))} ${rand(10, 20)}:${String(rand(0, 59)).padStart(2, "0")}:00` + : null; + const alertedAt = `${detectedAt.slice(0, 11)}${rand(8, 18)}:${String(rand(0, 59)).padStart(2, "0")}:05`; + + const types = [ + { + type: "threshold", + severity: rand(0, 1) ? "critical" : "warning", + metric: "spend", + value: rand(20000, 80000), + threshold: 20000, + msg: `${user.name}: spend $${rand(200, 800).toFixed(2)} exceeds limit $200.00`, + }, + { + type: "zscore", + severity: "warning", + metric: "requests", + value: rand(150, 300), + threshold: 100, + msg: `${user.name}: 2.${rand(1, 9)} std devs above team mean (${rand(150, 300)} requests)`, + }, + { + type: "trend", + severity: "warning", + metric: "tokens", + value: rand(3, 6) * 1000000, + threshold: 1000000, + msg: `Token spike: ${rand(3, 6)}.${rand(1, 9)}x ${user.name}'s 7-day average`, + }, + { + type: "trend", + severity: "warning", + metric: "model_shift", + value: rand(30, 70), + threshold: 20, + msg: `${user.name}: ${user.primaryModel} usage jumped from ${rand(3, 10)}% to ${rand(35, 70)}%`, + }, + ]; + const anomaly = pick(types); + + const result = anomalyStmt.run( + user.email, + anomaly.type, + anomaly.severity, + anomaly.metric, + anomaly.value, + anomaly.threshold, + anomaly.msg, + detectedAt, + resolvedAt, + alertedAt, + user.primaryModel, + "agent", + rand(10, 50), + ); + const anomalyId = Number(result.lastInsertRowid); + + const status = isResolved ? "resolved" : Math.random() < 0.3 ? "acknowledged" : "open"; + const acknowledgedAt = + status !== "open" + ? `${detectedAt.slice(0, 11)}${rand(9, 19)}:${String(rand(0, 59)).padStart(2, "0")}:00` + : null; + const mttd = rand(1, 15); + const mtti = acknowledgedAt ? rand(5, 120) : null; + const mttr = isResolved ? rand(30, 480) : null; + + incidentStmt.run( + anomalyId, + user.email, + status, + detectedAt, + alertedAt, + acknowledgedAt, + resolvedAt, + mttd, + mtti, + mttr, + ); + } + }); + anomalyTx(); + + const dauStmt = db.prepare( + "INSERT INTO analytics_dau (date, dau, cli_dau, cloud_agent_dau, bugbot_dau) VALUES (?, ?, ?, ?, ?)", + ); + const dauTx = db.transaction(() => { + for (let d = 0; d < DAYS; d++) { + const date = dateStr(DAYS - 1 - d); + const isWeekend = new Date(date).getDay() % 6 === 0; + const base = isWeekend ? rand(8, 20) : rand(35, 55); + dauStmt.run(date, base, Math.floor(base * 0.9), rand(1, 5), rand(0, 2)); + } + }); + dauTx(); + + const modelUsageStmt = db.prepare( + "INSERT INTO analytics_model_usage (date, model, messages, users) VALUES (?, ?, ?, ?)", + ); + const modelTx = db.transaction(() => { + for (let d = 0; d < DAYS; d++) { + const date = dateStr(DAYS - 1 - d); + for (let m = 0; m < MODELS.length; m++) { + const weight = MODEL_WEIGHTS[m] ?? 1; + const messages = Math.floor(weight * rand(5, 15)); + const users = Math.min(rand(2, Math.floor(weight * 1.5)), TEAM_SIZE); + modelUsageStmt.run(date, MODELS[m], messages, users); + } + } + }); + modelTx(); + + const agentEditsStmt = db.prepare( + "INSERT INTO analytics_agent_edits (date, suggested_diffs, accepted_diffs, rejected_diffs, lines_suggested, lines_accepted, green_lines_accepted, red_lines_accepted) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ); + const agentTx = db.transaction(() => { + for (let d = 0; d < DAYS; d++) { + const date = dateStr(DAYS - 1 - d); + const suggested = rand(200, 800); + const accepted = Math.floor(suggested * (0.5 + Math.random() * 0.3)); + const rejected = suggested - accepted; + const linesSuggested = suggested * rand(5, 20); + const linesAccepted = Math.floor(linesSuggested * (0.4 + Math.random() * 0.3)); + agentEditsStmt.run( + date, + suggested, + accepted, + rejected, + linesSuggested, + linesAccepted, + Math.floor(linesAccepted * 0.7), + Math.floor(linesAccepted * 0.3), + ); + } + }); + agentTx(); + + const tabsStmt = db.prepare( + "INSERT INTO analytics_tabs (date, suggestions, accepts, rejects, lines_suggested, lines_accepted) VALUES (?, ?, ?, ?, ?, ?)", + ); + const tabsTx = db.transaction(() => { + for (let d = 0; d < DAYS; d++) { + const date = dateStr(DAYS - 1 - d); + const suggestions = rand(500, 2000); + const accepts = Math.floor(suggestions * (0.3 + Math.random() * 0.3)); + tabsStmt.run( + date, + suggestions, + accepts, + suggestions - accepts, + suggestions * rand(2, 5), + accepts * rand(2, 4), + ); + } + }); + tabsTx(); + + const mcpStmt = db.prepare( + "INSERT INTO analytics_mcp (date, tool_name, server_name, usage) VALUES (?, ?, ?, ?)", + ); + const mcpTx = db.transaction(() => { + for (let d = 0; d < DAYS; d++) { + const date = dateStr(DAYS - 1 - d); + for (const t of MCP_TOOLS) { + mcpStmt.run(date, t.tool, t.server, rand(5, 200)); + } + } + }); + mcpTx(); + + const extStmt = db.prepare( + "INSERT INTO analytics_file_extensions (date, extension, total_files, lines_accepted) VALUES (?, ?, ?, ?)", + ); + const extTx = db.transaction(() => { + for (let d = 0; d < DAYS; d++) { + const date = dateStr(DAYS - 1 - d); + for (const ext of EXTENSIONS) { + extStmt.run(date, ext, rand(10, 500), rand(50, 3000)); + } + } + }); + extTx(); + + const cvStmt = db.prepare( + "INSERT INTO analytics_client_versions (date, version, user_count, percentage) VALUES (?, ?, ?, ?)", + ); + const cvTx = db.transaction(() => { + for (let d = 0; d < DAYS; d++) { + const date = dateStr(DAYS - 1 - d); + let remaining = 100; + for (let i = 0; i < CLIENT_VERSIONS.length; i++) { + const isLast = i === CLIENT_VERSIONS.length - 1; + const pct = isLast ? remaining : Math.min(rand(5, 25), remaining); + remaining -= pct; + const users = Math.max(1, Math.floor((TEAM_SIZE * pct) / 100)); + cvStmt.run(date, CLIENT_VERSIONS[i], users, pct); + } + } + }); + cvTx(); + + const metaStmt = db.prepare("INSERT INTO metadata (key, value, updated_at) VALUES (?, ?, ?)"); + metaStmt.run("cycle_start", CYCLE_START, now); + metaStmt.run("cycle_end", CYCLE_END, now); + + db.close(); + console.log(`Mock database generated at ${DB_PATH}`); +} + +function createSchema(db: Database.Database) { + db.exec(` + CREATE TABLE IF NOT EXISTS members ( + email TEXT PRIMARY KEY, user_id TEXT, name TEXT NOT NULL, role TEXT NOT NULL, + is_removed INTEGER NOT NULL DEFAULT 0, + first_seen TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS daily_usage ( + date TEXT NOT NULL, email TEXT NOT NULL, user_id TEXT, + is_active INTEGER NOT NULL DEFAULT 0, + lines_added INTEGER NOT NULL DEFAULT 0, lines_deleted INTEGER NOT NULL DEFAULT 0, + accepted_lines_added INTEGER NOT NULL DEFAULT 0, accepted_lines_deleted INTEGER NOT NULL DEFAULT 0, + total_applies INTEGER NOT NULL DEFAULT 0, total_accepts INTEGER NOT NULL DEFAULT 0, + total_rejects INTEGER NOT NULL DEFAULT 0, total_tabs_shown INTEGER NOT NULL DEFAULT 0, + tabs_accepted INTEGER NOT NULL DEFAULT 0, composer_requests INTEGER NOT NULL DEFAULT 0, + chat_requests INTEGER NOT NULL DEFAULT 0, agent_requests INTEGER NOT NULL DEFAULT 0, + usage_based_reqs INTEGER NOT NULL DEFAULT 0, most_used_model TEXT, + tab_most_used_extension TEXT, client_version TEXT, + collected_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (date, email) + ); + CREATE INDEX IF NOT EXISTS idx_daily_email ON daily_usage(email); + CREATE INDEX IF NOT EXISTS idx_daily_date ON daily_usage(date); + CREATE TABLE IF NOT EXISTS spending ( + email TEXT NOT NULL, user_id TEXT, name TEXT, cycle_start TEXT NOT NULL, + spend_cents INTEGER NOT NULL DEFAULT 0, included_spend_cents INTEGER NOT NULL DEFAULT 0, + fast_premium_requests INTEGER NOT NULL DEFAULT 0, + monthly_limit_dollars REAL, hard_limit_override_dollars REAL NOT NULL DEFAULT 0, + collected_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (email, cycle_start) + ); + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, user_email TEXT NOT NULL, timestamp TEXT NOT NULL, + model TEXT NOT NULL, kind TEXT NOT NULL, max_mode INTEGER NOT NULL DEFAULT 0, + requests_cost_cents REAL NOT NULL DEFAULT 0, total_cents REAL NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, is_chargeable INTEGER NOT NULL DEFAULT 1, + is_headless INTEGER NOT NULL DEFAULT 0, + collected_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_events_user_ts ON usage_events(user_email, timestamp); + CREATE INDEX IF NOT EXISTS idx_events_ts ON usage_events(timestamp); + CREATE TABLE IF NOT EXISTS anomalies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, user_email TEXT NOT NULL, type TEXT NOT NULL, + severity TEXT NOT NULL, metric TEXT NOT NULL, value REAL NOT NULL, threshold REAL NOT NULL, + message TEXT NOT NULL, detected_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT, alerted_at TEXT, diagnosis_model TEXT, diagnosis_kind TEXT, diagnosis_delta REAL + ); + CREATE INDEX IF NOT EXISTS idx_anomalies_user ON anomalies(user_email); + CREATE INDEX IF NOT EXISTS idx_anomalies_open ON anomalies(resolved_at) WHERE resolved_at IS NULL; + CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, anomaly_id INTEGER NOT NULL REFERENCES anomalies(id), + user_email TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', + detected_at TEXT NOT NULL DEFAULT (datetime('now')), alerted_at TEXT, + acknowledged_at TEXT, resolved_at TEXT, + mttd_minutes REAL, mtti_minutes REAL, mttr_minutes REAL + ); + CREATE INDEX IF NOT EXISTS idx_incidents_status ON incidents(status); + CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS daily_spend ( + date TEXT NOT NULL, email TEXT NOT NULL, spend_cents INTEGER NOT NULL DEFAULT 0, + cycle_start TEXT NOT NULL, collected_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (date, email, cycle_start) + ); + CREATE INDEX IF NOT EXISTS idx_daily_spend_email ON daily_spend(email); + CREATE INDEX IF NOT EXISTS idx_daily_spend_date ON daily_spend(date); + CREATE TABLE IF NOT EXISTS billing_groups ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, member_count INTEGER NOT NULL DEFAULT 0, + spend_cents INTEGER NOT NULL DEFAULT 0, collected_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS group_members ( + group_id TEXT NOT NULL, email TEXT NOT NULL, joined_at TEXT, + PRIMARY KEY (group_id, email) + ); + CREATE TABLE IF NOT EXISTS analytics_dau ( + date TEXT PRIMARY KEY, dau INTEGER NOT NULL DEFAULT 0, cli_dau INTEGER NOT NULL DEFAULT 0, + cloud_agent_dau INTEGER NOT NULL DEFAULT 0, bugbot_dau INTEGER NOT NULL DEFAULT 0, + collected_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS analytics_model_usage ( + date TEXT NOT NULL, model TEXT NOT NULL, messages INTEGER NOT NULL DEFAULT 0, + users INTEGER NOT NULL DEFAULT 0, collected_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (date, model) + ); + CREATE TABLE IF NOT EXISTS analytics_agent_edits ( + date TEXT PRIMARY KEY, suggested_diffs INTEGER NOT NULL DEFAULT 0, + accepted_diffs INTEGER NOT NULL DEFAULT 0, rejected_diffs INTEGER NOT NULL DEFAULT 0, + lines_suggested INTEGER NOT NULL DEFAULT 0, lines_accepted INTEGER NOT NULL DEFAULT 0, + green_lines_accepted INTEGER NOT NULL DEFAULT 0, red_lines_accepted INTEGER NOT NULL DEFAULT 0, + collected_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS analytics_tabs ( + date TEXT PRIMARY KEY, suggestions INTEGER NOT NULL DEFAULT 0, + accepts INTEGER NOT NULL DEFAULT 0, rejects INTEGER NOT NULL DEFAULT 0, + lines_suggested INTEGER NOT NULL DEFAULT 0, lines_accepted INTEGER NOT NULL DEFAULT 0, + collected_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS analytics_mcp ( + date TEXT NOT NULL, tool_name TEXT NOT NULL, server_name TEXT NOT NULL, + usage INTEGER NOT NULL DEFAULT 0, collected_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (date, tool_name, server_name) + ); + CREATE TABLE IF NOT EXISTS analytics_file_extensions ( + date TEXT NOT NULL, extension TEXT NOT NULL, total_files INTEGER NOT NULL DEFAULT 0, + lines_accepted INTEGER NOT NULL DEFAULT 0, collected_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (date, extension) + ); + CREATE TABLE IF NOT EXISTS analytics_client_versions ( + date TEXT NOT NULL, version TEXT NOT NULL, user_count INTEGER NOT NULL DEFAULT 0, + percentage REAL NOT NULL DEFAULT 0, collected_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (date, version) + ); + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS collection_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, + started_at TEXT NOT NULL DEFAULT (datetime('now')), completed_at TEXT, + records_count INTEGER DEFAULT 0, error TEXT + ); + `); +} + +run(); diff --git a/src/lib/db.ts b/src/lib/db.ts index d1080e5..6490398 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -19,7 +19,7 @@ import type { } from "./types"; import { DEFAULT_CONFIG } from "./types"; -const DB_PATH = path.join(process.cwd(), "data", "tracker.db"); +const DB_PATH = process.env.DATABASE_PATH ?? path.join(process.cwd(), "data", "tracker.db"); let dbInstance: Database.Database | null = null; From a5ac129e25d45afc73fad452ba4436e4738e04ec Mon Sep 17 00:00:00 2001 From: Ofer Shapira Date: Tue, 17 Feb 2026 23:31:22 +0200 Subject: [PATCH 3/4] feat: rewrite anomaly detection to be spend-focused with smart batching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detection was too noisy — model shift and drift alerts flagged normal usage patterns, request-count alerts ignored cost, and 23+ individual Slack messages bombarded the channel. Detection changes: - Remove model shift detection (using an expensive model isn't inherently bad) - Remove drift/P75 detection (just identified power users, not actionable) - Remove request-based z-score (high request count on cheap models is fine) - Add daily spend spike: flags when today's spend > 5x personal average - Add cycle spend outlier: flags when cycle spend > 10x active team median - Z-score now spend-only, computed against active users (spend > $0) - Minimum $50/day floor to avoid alerts on trivial amounts - Critical threshold raised to 3x multiplier (was 2x) Alerting changes: - Slack uses bot token + chat.postMessage (replaces webhook) - Batch alerts: ≤3 anomalies send individual messages, >3 sends summary - Summary auto-chunks long lists to respect Slack's block character limit - Add logging to Slack and email modules (missing config, send success/failure) - Remove dead sendSlackResolution function - Pass DASHBOARD_URL to alert links Config changes: - New trend settings: spendSpikeMultiplier, spendSpikeLookbackDays, cycleOutlierMultiplier - getConfig() merges stored config with defaults for safe migration Co-authored-by: Cursor --- .cursor/rules/project-context.mdc | 8 +- README.md | 44 +++--- scripts/generate-mock-db.ts | 16 +- src/app/settings/settings-client.tsx | 24 +-- src/lib/alerts/email.ts | 19 ++- src/lib/alerts/index.ts | 28 ++-- src/lib/alerts/slack.ts | 160 +++++++++++++++++--- src/lib/anomaly/trends.ts | 209 +++++++++------------------ src/lib/anomaly/zscore.ts | 101 +++++-------- src/lib/db.ts | 8 +- src/lib/types.ts | 14 +- 11 files changed, 336 insertions(+), 295 deletions(-) diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 5334f4c..ee93992 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -43,11 +43,11 @@ Single cron endpoint `POST /api/cron` does both: collect → detect → alert in | `src/lib/collector.ts` | Data collection pipeline (members, daily usage, spending, events, analytics, groups) | | `src/lib/anomaly/detector.ts` | Orchestrator — runs all 3 detection layers, deduplicates | | `src/lib/anomaly/thresholds.ts` | Layer 1: static limits (spend, requests, tokens) | -| `src/lib/anomaly/zscore.ts` | Layer 2: statistical z-score vs team mean | -| `src/lib/anomaly/trends.ts` | Layer 3: personal spikes, drift above P75, model shift | +| `src/lib/anomaly/zscore.ts` | Layer 2: statistical z-score on daily spend vs active team mean | +| `src/lib/anomaly/trends.ts` | Layer 3: daily spend spikes, cycle spend outliers | | `src/lib/incidents.ts` | Incident lifecycle: create, alert, acknowledge, resolve + MTTD/MTTI/MTTR | | `src/lib/alerts/slack.ts` | Slack bot token + chat.postMessage with block-kit messages | -| `src/lib/alerts/email.ts` | SMTP email with HTML templates | +| `src/lib/alerts/email.ts` | Email alerts via Resend with HTML templates | | `src/lib/date-utils.ts` | Date formatting helpers (formatDateShort, formatDateTick, formatDateLabel) | | `src/lib/format-utils.ts` | Model name shortening (MODEL_MAP regex → short labels) | | `src/app/api/cron/route.ts` | Main cron endpoint (collect + detect + alert) | @@ -85,5 +85,5 @@ members, daily_usage, spending, usage_events, anomalies, incidents, config, coll ## Important Caveats - API response shapes may not exactly match the live Cursor API. If real responses differ, update `src/lib/types.ts` and `src/lib/cursor-client.ts`. -- Trend detection can produce duplicate dedup keys (spike + drift both emit `trend:tokens` for same user) — first one wins, second is silently dropped. +- Trend detection can produce duplicate dedup keys (spend spike + cycle outlier both emit `trend:spend` for same user) — first one wins, second is silently dropped. - CLI scripts (`npm run collect`, `npm run detect`) do NOT auto-load `.env` — use `source .env && export CURSOR_ADMIN_API_KEY` or run via the Next.js dev server cron endpoint instead. \ No newline at end of file diff --git a/README.md b/README.md index 6ff1e52..d3ecb55 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,12 @@ Developer uses Cursor → API collects data hourly → Engine detects anomaly ### How It Works -| What happens | Example | -| -------------------------------------------- | ------------------------------------------------------- | -| A developer exceeds the spend limit | `Bob spent $82 this cycle (limit: $50)` → Slack alert | -| Someone's usage is 3x their personal average | `Token spike: 4.2x Alice's 7-day average` → Slack alert | -| A user is statistically far from the team | `Bob: 2.8 std devs above team mean` → Slack alert | -| Someone shifts to expensive models | `Opus usage jumped from 5% to 45%` → Slack alert | -| Usage drifts above team P75 for days | `Above team P75 for 5 of last 6 days` → Slack alert | +| What happens | Example | +| ------------------------------------------ | ----------------------------------------------------------------------------- | +| A developer exceeds the spend limit | `Bob spent $82 this cycle (limit: $50)` → Slack alert | +| Someone's daily spend spikes | `Alice: daily spend spiked to $214 (4.2x her 7-day avg of $51)` → Slack alert | +| A user's cycle spend is far above the team | `Bob: cycle spend $957 is 5.1x the team median ($188)` → Slack alert | +| A user is statistically far from the team | `Bob: daily spend $214 is 3.2σ above team mean ($42)` → Slack alert | Every alert includes who, what model, how much, and a link to their dashboard page so you can investigate immediately. @@ -66,11 +65,11 @@ Every alert includes who, what model, how much, and a link to their dashboard pa ### Three-Layer Anomaly Detection -| Layer | Method | What it catches | -| -------------- | ------------- | --------------------------------------------------------------------------- | -| **Thresholds** | Static limits | Optional hard caps on spend, requests, or tokens (disabled by default) | -| **Z-Score** | Statistical | User 2+ standard deviations above team mean | -| **Trends** | Behavioral | Personal spikes, sustained drift above P75, model shift to expensive models | +| Layer | Method | What it catches | +| -------------- | ------------- | ----------------------------------------------------------------------------- | +| **Thresholds** | Static limits | Optional hard caps on spend, requests, or tokens (disabled by default) | +| **Z-Score** | Statistical | User daily spend 2.5+ standard deviations above team mean (active users only) | +| **Trends** | Spend-based | Daily spend spikes vs personal average, cycle spend outliers vs team median | ### Incident Lifecycle (MTTD / MTTI / MTTR) @@ -90,7 +89,7 @@ Anomaly Detected ──→ Alert Sent ──→ Acknowledged ──→ Resolved ### Rich Alerting -- **Slack**: Block Kit messages via bot token (`chat.postMessage`) with severity, user, model, value vs threshold, and dashboard links +- **Slack**: Block Kit messages via bot token (`chat.postMessage`) with severity, user, model, value vs threshold, and dashboard links. Batches alerts automatically (individual messages for 1-3 anomalies, single summary for 4+). - **Email**: HTML-formatted alerts via [Resend](https://resend.com) (one API key, no SMTP config) ### Web Dashboard @@ -254,15 +253,16 @@ flowchart TB All detection thresholds are configurable via the Settings page or the API: -| Setting | Default | What it does | -| -------------------- | ------- | ------------------------------------------------- | -| Max spend per cycle | 0 (off) | Alert when a user exceeds this in a billing cycle | -| Max requests per day | 0 (off) | Alert on excessive daily request count | -| Max tokens per day | 0 (off) | Alert on excessive daily token consumption | -| Z-score multiplier | 2.5 | How many standard deviations above mean to flag | -| Z-score window | 7 days | Historical window for statistical comparison | -| Spike multiplier | 3.0x | Alert when today > N× user's personal average | -| Drift days above P75 | 3 | Consecutive days above team P75 to flag | +| Setting | Default | What it does | +| ------------------------ | ------- | -------------------------------------------------------------- | +| Max spend per cycle | 0 (off) | Alert when a user exceeds this in a billing cycle | +| Max requests per day | 0 (off) | Alert on excessive daily request count | +| Max tokens per day | 0 (off) | Alert on excessive daily token consumption | +| Z-score multiplier | 2.5 | How many standard deviations above mean to flag (spend + reqs) | +| Z-score window | 7 days | Historical window for statistical comparison | +| Spend spike multiplier | 5.0x | Alert when today's spend > N× user's personal daily average | +| Spend spike lookback | 7 days | How many days of history to compare against | +| Cycle outlier multiplier | 10.0x | Alert when cycle spend > N× team median (active users only) | --- diff --git a/scripts/generate-mock-db.ts b/scripts/generate-mock-db.ts index 71a8382..a7167e0 100644 --- a/scripts/generate-mock-db.ts +++ b/scripts/generate-mock-db.ts @@ -458,18 +458,18 @@ function run() { { type: "trend", severity: "warning", - metric: "tokens", - value: rand(3, 6) * 1000000, - threshold: 1000000, - msg: `Token spike: ${rand(3, 6)}.${rand(1, 9)}x ${user.name}'s 7-day average`, + metric: "spend", + value: rand(5000, 20000), + threshold: rand(2000, 5000), + msg: `${user.name}: daily spend spiked to $${rand(50, 200)} (${rand(3, 6)}.${rand(1, 9)}x their 7-day avg) — model: ${user.primaryModel}`, }, { type: "trend", severity: "warning", - metric: "model_shift", - value: rand(30, 70), - threshold: 20, - msg: `${user.name}: ${user.primaryModel} usage jumped from ${rand(3, 10)}% to ${rand(35, 70)}%`, + metric: "spend", + value: rand(30000, 100000), + threshold: rand(10000, 30000), + msg: `${user.name}: cycle spend $${rand(300, 1000)} is ${rand(3, 6)}.${rand(1, 9)}x the team median — model: ${user.primaryModel}`, }, ]; const anomaly = pick(types); diff --git a/src/app/settings/settings-client.tsx b/src/app/settings/settings-client.tsx index b446343..518fdad 100644 --- a/src/app/settings/settings-client.tsx +++ b/src/app/settings/settings-client.tsx @@ -129,41 +129,41 @@ export function SettingsClient({ config: initial }: SettingsClientProps) { />
-
+
setConfig({ ...config, - trends: { ...config.trends, spikeMultiplier: v }, + trends: { ...config.trends, spendSpikeMultiplier: v }, }) } step={0.5} - hint="today > N × avg" + hint="today spend > N × avg" /> setConfig({ ...config, - trends: { ...config.trends, spikeLookbackDays: v }, + trends: { ...config.trends, spendSpikeLookbackDays: v }, }) } unit="days" /> setConfig({ ...config, - trends: { ...config.trends, driftDaysAboveP75: v }, + trends: { ...config.trends, cycleOutlierMultiplier: v }, }) } - unit="days" - hint="consecutive > P75" + step={0.5} + hint="cycle spend > N × team median" />
diff --git a/src/lib/alerts/email.ts b/src/lib/alerts/email.ts index c67572c..41fe59e 100644 --- a/src/lib/alerts/email.ts +++ b/src/lib/alerts/email.ts @@ -15,8 +15,6 @@ function formatValue(metric: string, value: number): string { return `${(value / 1_000_000).toFixed(2)}M tokens`; case "requests": return `${value.toFixed(0)} requests`; - case "model_shift": - return `${value.toFixed(0)}%`; default: return `${value}`; } @@ -60,10 +58,16 @@ export async function sendEmailAlert( options: { to?: string; dashboardUrl?: string } = {}, ): Promise { const resend = getClient(); - if (!resend) return false; + if (!resend) { + console.warn("[email] Skipping alert — missing RESEND_API_KEY"); + return false; + } const to = options.to ?? process.env.ALERT_EMAIL_TO; - if (!to) return false; + if (!to) { + console.warn("[email] Skipping alert — missing ALERT_EMAIL_TO"); + return false; + } const from = process.env.RESEND_FROM ?? "Cursor Tracker "; const severityPrefix = anomaly.severity === "critical" ? "[CRITICAL]" : "[WARNING]"; @@ -76,12 +80,13 @@ export async function sendEmailAlert( html: buildHtml(anomaly, incident, options.dashboardUrl), }); if (error) { - console.error("[email] Resend error:", error.message); + console.error("[email] Resend API error:", error.message); return false; } + console.log(`[email] Alert sent to ${to} for ${anomaly.userEmail}`); return true; - } catch { - console.error("[email] Failed to send alert email"); + } catch (err) { + console.error("[email] Failed to send:", err instanceof Error ? err.message : err); return false; } } diff --git a/src/lib/alerts/index.ts b/src/lib/alerts/index.ts index 3adf395..c200739 100644 --- a/src/lib/alerts/index.ts +++ b/src/lib/alerts/index.ts @@ -1,5 +1,5 @@ import type { Anomaly, Incident } from "../types"; -import { sendSlackAlert } from "./slack"; +import { sendSlackBatch } from "./slack"; import { sendEmailAlert } from "./email"; import { markIncidentAlerted } from "../incidents"; @@ -7,27 +7,31 @@ export async function sendAlerts( pairs: Array<{ anomaly: Anomaly; incident: Incident }>, options: { dashboardUrl?: string } = {}, ): Promise<{ slack: number; email: number; failed: number }> { - let slack = 0; - let email = 0; let failed = 0; - for (const { anomaly, incident } of pairs) { - const slackOk = await sendSlackAlert(anomaly, incident, { - dashboardUrl: options.dashboardUrl, - }); - if (slackOk) slack++; + const slackSent = await sendSlackBatch(pairs, options); + const slackOk = slackSent > 0; - const emailOk = await sendEmailAlert(anomaly, incident, { + let emailSent = 0; + const emailResults: boolean[] = []; + for (const { anomaly, incident } of pairs) { + const ok = await sendEmailAlert(anomaly, incident, { dashboardUrl: options.dashboardUrl, }); - if (emailOk) email++; + emailResults.push(ok); + if (ok) emailSent++; + } - if (slackOk || emailOk) { + for (let i = 0; i < pairs.length; i++) { + const pair = pairs[i]; + if (!pair) continue; + const { anomaly, incident } = pair; + if (slackOk || emailResults[i]) { markIncidentAlerted(incident.id ?? 0, anomaly.id ?? 0); } else { failed++; } } - return { slack, email, failed }; + return { slack: slackSent, email: emailSent, failed }; } diff --git a/src/lib/alerts/slack.ts b/src/lib/alerts/slack.ts index cbfdbe6..ded1cc5 100644 --- a/src/lib/alerts/slack.ts +++ b/src/lib/alerts/slack.ts @@ -1,6 +1,7 @@ import type { Anomaly, Incident } from "../types"; const SLACK_API_URL = "https://slack.com/api/chat.postMessage"; +const BATCH_THRESHOLD = 3; interface SlackBlock { type: string; @@ -21,8 +22,6 @@ function formatValue(metric: string, value: number): string { return `${(value / 1_000_000).toFixed(2)}M`; case "requests": return `${value.toFixed(0)}`; - case "model_shift": - return `${value.toFixed(0)}%`; default: return `${value}`; } @@ -76,7 +75,7 @@ function buildAlertBlocks( type: "section", text: { type: "mrkdwn", - text: `*Diagnosis:* Primary model — \`${anomaly.diagnosisModel}\`${anomaly.diagnosisDelta ? ` (delta: ${formatValue(anomaly.metric, anomaly.diagnosisDelta)})` : ""}`, + text: `*Primary model:* \`${anomaly.diagnosisModel}\``, }, }); } @@ -104,6 +103,111 @@ function buildAlertBlocks( return blocks; } +function buildSummaryBlocks( + pairs: Array<{ anomaly: Anomaly; incident: Incident }>, + dashboardUrl?: string, +): SlackBlock[] { + const critical = pairs.filter((p) => p.anomaly.severity === "critical"); + const warnings = pairs.filter((p) => p.anomaly.severity === "warning"); + + const lines = pairs.map(({ anomaly, incident }) => { + const emoji = severityEmoji(anomaly.severity); + return `${emoji} *#${incident.id}* ${anomaly.userEmail}: ${anomaly.message}`; + }); + + const blocks: SlackBlock[] = [ + { + type: "header", + text: { + type: "plain_text", + text: `Cursor Usage — ${pairs.length} anomalies detected`, + emoji: true, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*${critical.length}* critical · *${warnings.length}* warning`, + }, + }, + ]; + + const MAX_BLOCK_CHARS = 2800; + let chunk: string[] = []; + let chunkLen = 0; + + for (const line of lines) { + if (chunkLen + line.length + 1 > MAX_BLOCK_CHARS && chunk.length > 0) { + blocks.push({ type: "section", text: { type: "mrkdwn", text: chunk.join("\n") } }); + chunk = []; + chunkLen = 0; + } + chunk.push(line); + chunkLen += line.length + 1; + } + if (chunk.length > 0) { + blocks.push({ type: "section", text: { type: "mrkdwn", text: chunk.join("\n") } }); + } + + if (dashboardUrl) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `<${dashboardUrl}/anomalies|View all anomalies>`, + }, + }); + } + + blocks.push({ + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Detected at ${new Date().toISOString()} · cursor-usage-tracker`, + }, + ], + }); + + return blocks; +} + +async function postToSlack( + token: string, + channel: string, + text: string, + blocks: SlackBlock[], +): Promise { + try { + const response = await fetch(SLACK_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ channel, text, blocks }), + }); + + if (!response.ok) { + console.error(`[slack] HTTP error: ${response.status} ${response.statusText}`); + return false; + } + + const data = (await response.json()) as { ok: boolean; error?: string }; + if (!data.ok) { + console.error(`[slack] API error: ${data.error}`); + return false; + } + + console.log("[slack] Message sent successfully"); + return true; + } catch (err) { + console.error("[slack] Failed to send:", err instanceof Error ? err.message : err); + return false; + } +} + export async function sendSlackAlert( anomaly: Anomaly, incident: Incident, @@ -111,25 +215,43 @@ export async function sendSlackAlert( ): Promise { const token = process.env.SLACK_BOT_TOKEN; const channel = process.env.SLACK_CHANNEL_ID; - if (!token || !channel) return false; + if (!token || !channel) { + console.warn("[slack] Skipping alert — missing SLACK_BOT_TOKEN or SLACK_CHANNEL_ID"); + return false; + } const blocks = buildAlertBlocks(anomaly, incident, options.dashboardUrl); + const text = `${severityEmoji(anomaly.severity)} ${anomaly.message} — ${anomaly.userEmail}`; - const response = await fetch(SLACK_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - channel, - text: `${severityEmoji(anomaly.severity)} ${anomaly.message} — ${anomaly.userEmail}`, - blocks, - }), - }); + return postToSlack(token, channel, text, blocks); +} + +export async function sendSlackBatch( + pairs: Array<{ anomaly: Anomaly; incident: Incident }>, + options: { dashboardUrl?: string } = {}, +): Promise { + const token = process.env.SLACK_BOT_TOKEN; + const channel = process.env.SLACK_CHANNEL_ID; + if (!token || !channel) { + console.warn("[slack] Skipping batch — missing SLACK_BOT_TOKEN or SLACK_CHANNEL_ID"); + return 0; + } - if (!response.ok) return false; + console.log( + `[slack] Sending ${pairs.length} anomalies (${pairs.length <= BATCH_THRESHOLD ? "individual" : "summary"} mode)`, + ); + + if (pairs.length <= BATCH_THRESHOLD) { + let sent = 0; + for (const { anomaly, incident } of pairs) { + const ok = await sendSlackAlert(anomaly, incident, options); + if (ok) sent++; + } + return sent; + } - const data = (await response.json()) as { ok: boolean }; - return data.ok; + const blocks = buildSummaryBlocks(pairs, options.dashboardUrl); + const text = `Cursor Usage — ${pairs.length} anomalies detected`; + const ok = await postToSlack(token, channel, text, blocks); + return ok ? pairs.length : 0; } diff --git a/src/lib/anomaly/trends.ts b/src/lib/anomaly/trends.ts index 7d42b39..87c08a9 100644 --- a/src/lib/anomaly/trends.ts +++ b/src/lib/anomaly/trends.ts @@ -1,209 +1,144 @@ import type { Anomaly, DetectionConfig } from "../types"; import { getDb } from "../db"; -const EXPENSIVE_MODELS = new Set([ - "claude-4.6-opus-high-thinking", - "claude-4.6-opus-high", - "claude-4.6-opus-max-thinking", - "claude-4.6-opus-max", - "claude-4.5-opus-high-thinking", - "claude-4.5-opus-high", - "gpt-5.3-codex", - "gpt-5.3-codex-high", - "gpt-5.3-codex-xhigh", - "gpt-5.2-codex", -]); +const MIN_DAILY_SPEND_CENTS = 5000; +const MIN_CYCLE_MEDIAN_CENTS = 1000; export function detectTrendAnomalies(config: DetectionConfig): Anomaly[] { const db = getDb(); const anomalies: Anomaly[] = []; const now = new Date().toISOString(); - const { spikeMultiplier, spikeLookbackDays, driftDaysAboveP75 } = config.trends; + const { spendSpikeMultiplier, spendSpikeLookbackDays, cycleOutlierMultiplier } = config.trends; - detectSpikes(db, anomalies, now, spikeMultiplier, spikeLookbackDays); - detectDrift(db, anomalies, now, driftDaysAboveP75); - detectModelShift(db, anomalies, now); + detectSpendSpikes(db, anomalies, now, spendSpikeMultiplier, spendSpikeLookbackDays); + detectCycleOutliers(db, anomalies, now, cycleOutlierMultiplier); return anomalies; } -function detectSpikes( +function detectSpendSpikes( db: ReturnType, anomalies: Anomaly[], now: string, spikeMultiplier: number, lookbackDays: number, ): void { - const latestDate = db - .prepare("SELECT MAX(date) as d FROM daily_usage WHERE is_active = 1") - .get() as { d: string | null }; + const latestDate = db.prepare("SELECT MAX(date) as d FROM daily_spend").get() as { + d: string | null; + }; if (!latestDate.d) return; const targetDate = latestDate.d; - const todayByUser = db + const todaySpend = db .prepare( - `SELECT email, agent_requests, usage_based_reqs, most_used_model - FROM daily_usage - WHERE date = ? AND is_active = 1`, + `SELECT ds.email, ds.spend_cents, COALESCE(m.name, ds.email) as name, + COALESCE(du.most_used_model, '') as most_used_model + FROM (SELECT email, MAX(spend_cents) as spend_cents FROM daily_spend WHERE date = ? GROUP BY email) ds + LEFT JOIN members m ON ds.email = m.email + LEFT JOIN daily_usage du ON ds.email = du.email AND du.date = ?`, ) - .all(targetDate) as Array<{ + .all(targetDate, targetDate) as Array<{ email: string; - agent_requests: number; - usage_based_reqs: number; + spend_cents: number; + name: string; most_used_model: string; }>; - for (const user of todayByUser) { - if (user.agent_requests < 10) continue; + for (const user of todaySpend) { + if (user.spend_cents < MIN_DAILY_SPEND_CENTS) continue; const history = db .prepare( - `SELECT AVG(agent_requests) as avg_requests - FROM daily_usage - WHERE email = ? AND date < ? AND date >= date(?, ?) AND is_active = 1`, + `SELECT AVG(spend) as avg_spend FROM ( + SELECT email, date, MAX(spend_cents) as spend + FROM daily_spend + WHERE email = ? AND date < ? AND date >= date(?, ?) + GROUP BY email, date + )`, ) .get(user.email, targetDate, targetDate, `-${lookbackDays} days`) as { - avg_requests: number | null; + avg_spend: number | null; }; - if (!history.avg_requests || history.avg_requests < 5) continue; + if (!history.avg_spend || history.avg_spend < 100) continue; - const ratio = user.agent_requests / history.avg_requests; + const ratio = user.spend_cents / history.avg_spend; if (ratio > spikeMultiplier) { + const todayDollars = (user.spend_cents / 100).toFixed(2); + const avgDollars = (history.avg_spend / 100).toFixed(2); anomalies.push({ userEmail: user.email, type: "trend", - severity: ratio > spikeMultiplier * 2 ? "critical" : "warning", - metric: "requests", - value: user.agent_requests, - threshold: history.avg_requests * spikeMultiplier, - message: `Request spike: ${ratio.toFixed(1)}x their ${lookbackDays}-day average (${history.avg_requests.toFixed(0)} → ${user.agent_requests}) — model: ${user.most_used_model}`, + severity: ratio > spikeMultiplier * 3 ? "critical" : "warning", + metric: "spend", + value: user.spend_cents, + threshold: history.avg_spend * spikeMultiplier, + message: `${user.name}: daily spend spiked to $${todayDollars} (${ratio.toFixed(1)}x their ${lookbackDays}-day avg of $${avgDollars}) — model: ${user.most_used_model || "unknown"}`, detectedAt: now, resolvedAt: null, alertedAt: null, diagnosisModel: user.most_used_model || null, diagnosisKind: null, - diagnosisDelta: user.agent_requests - history.avg_requests, + diagnosisDelta: user.spend_cents - history.avg_spend, }); } } } -function detectDrift( +function detectCycleOutliers( db: ReturnType, anomalies: Anomaly[], now: string, - driftDays: number, + outlierMultiplier: number, ): void { - const recentDays = db + const cycleSpend = db .prepare( - `SELECT email, date, agent_requests - FROM daily_usage - WHERE date >= date('now', ?) AND is_active = 1`, + `SELECT s.email, s.name, s.spend_cents, s.fast_premium_requests, + COALESCE(du.most_used_model, '') as most_used_model + FROM spending s + LEFT JOIN ( + SELECT email, most_used_model FROM daily_usage + WHERE date = (SELECT MAX(date) FROM daily_usage WHERE is_active = 1) AND is_active = 1 + ) du ON s.email = du.email + WHERE s.cycle_start = (SELECT MAX(cycle_start) FROM spending) + AND s.spend_cents > 0`, ) - .all(`-${driftDays + 1} days`) as Array<{ + .all() as Array<{ email: string; - date: string; - agent_requests: number; + name: string; + spend_cents: number; + fast_premium_requests: number; + most_used_model: string; }>; - const allDailyRequests = recentDays.map((r) => r.agent_requests).filter((r) => r > 0); - if (allDailyRequests.length === 0) return; + if (cycleSpend.length < 5) return; - const sorted = [...allDailyRequests].sort((a, b) => a - b); - const p75Index = Math.floor(sorted.length * 0.75); - const p75 = sorted[p75Index] ?? 0; + const spends = cycleSpend.map((s) => s.spend_cents).sort((a, b) => a - b); + const medianIndex = Math.floor(spends.length / 2); + const median = spends[medianIndex] ?? 0; - const byUser = new Map(); - for (const row of recentDays) { - const arr = byUser.get(row.email) ?? []; - arr.push(row.agent_requests); - byUser.set(row.email, arr); - } + if (median < MIN_CYCLE_MEDIAN_CENTS) return; - for (const [email, dailyReqs] of byUser) { - const daysAbove = dailyReqs.filter((r) => r > p75).length; - if (daysAbove >= driftDays) { - const avgReqs = dailyReqs.reduce((a, b) => a + b, 0) / dailyReqs.length; + for (const user of cycleSpend) { + const ratio = user.spend_cents / median; + if (ratio > outlierMultiplier) { + const userDollars = (user.spend_cents / 100).toFixed(2); + const medianDollars = (median / 100).toFixed(2); anomalies.push({ - userEmail: email, - type: "trend", - severity: "warning", - metric: "requests", - value: avgReqs, - threshold: p75, - message: `Sustained high usage: above team P75 (${p75} reqs) for ${daysAbove} of last ${dailyReqs.length} days (avg: ${avgReqs.toFixed(0)})`, - detectedAt: now, - resolvedAt: null, - alertedAt: null, - diagnosisModel: null, - diagnosisKind: null, - diagnosisDelta: avgReqs - p75, - }); - } - } -} - -function detectModelShift(db: ReturnType, anomalies: Anomaly[], now: string): void { - const latestDate = db - .prepare("SELECT MAX(date) as d FROM daily_usage WHERE is_active = 1") - .get() as { d: string | null }; - - if (!latestDate.d) return; - const targetDate = latestDate.d; - - const todayModels = db - .prepare( - `SELECT email, most_used_model - FROM daily_usage - WHERE date = ? AND is_active = 1 AND most_used_model != ''`, - ) - .all(targetDate) as Array<{ email: string; most_used_model: string }>; - - const historyModels = db - .prepare( - `SELECT email, most_used_model, COUNT(*) as days - FROM daily_usage - WHERE date < ? AND date >= date(?, '-7 days') AND is_active = 1 AND most_used_model != '' - GROUP BY email, most_used_model`, - ) - .all(targetDate, targetDate) as Array<{ email: string; most_used_model: string; days: number }>; - - const histByUser = new Map>(); - for (const row of historyModels) { - const models = histByUser.get(row.email) ?? new Map(); - models.set(row.most_used_model, row.days); - histByUser.set(row.email, models); - } - - for (const row of todayModels) { - if (!EXPENSIVE_MODELS.has(row.most_used_model)) continue; - - const hist = histByUser.get(row.email); - if (!hist) continue; - - const totalHistDays = Array.from(hist.values()).reduce((a, b) => a + b, 0); - if (totalHistDays < 3) continue; - - const expensiveDaysHist = hist.get(row.most_used_model) ?? 0; - const histPct = expensiveDaysHist / totalHistDays; - - if (histPct < 0.3) { - anomalies.push({ - userEmail: row.email, + userEmail: user.email, type: "trend", - severity: "warning", - metric: "model_shift", - value: 100, - threshold: histPct * 100, - message: `Model shift: switched to ${row.most_used_model} today (previously used ${(histPct * 100).toFixed(0)}% of days)`, + severity: ratio > outlierMultiplier * 3 ? "critical" : "warning", + metric: "spend", + value: user.spend_cents, + threshold: median * outlierMultiplier, + message: `${user.name}: cycle spend $${userDollars} is ${ratio.toFixed(1)}x the team median ($${medianDollars}) — model: ${user.most_used_model || "unknown"}, ${user.fast_premium_requests} premium reqs`, detectedAt: now, resolvedAt: null, alertedAt: null, - diagnosisModel: row.most_used_model, + diagnosisModel: user.most_used_model || null, diagnosisKind: null, - diagnosisDelta: (1 - histPct) * 100, + diagnosisDelta: user.spend_cents - median, }); } } diff --git a/src/lib/anomaly/zscore.ts b/src/lib/anomaly/zscore.ts index 7e75be5..a4eb8b1 100644 --- a/src/lib/anomaly/zscore.ts +++ b/src/lib/anomaly/zscore.ts @@ -1,6 +1,8 @@ import type { Anomaly, DetectionConfig } from "../types"; import { getDb } from "../db"; +const MIN_DAILY_SPEND_CENTS = 5000; + function computeZScore(value: number, mean: number, stddev: number): number { if (stddev === 0) return value > mean ? Infinity : 0; return (value - mean) / stddev; @@ -10,96 +12,63 @@ export function detectZScoreAnomalies(config: DetectionConfig): Anomaly[] { const db = getDb(); const anomalies: Anomaly[] = []; const now = new Date().toISOString(); - const { multiplier, windowDays } = config.zscore; + const { multiplier } = config.zscore; - const latestDate = db - .prepare("SELECT MAX(date) as d FROM daily_usage WHERE is_active = 1") - .get() as { d: string | null }; + const latestDate = db.prepare("SELECT MAX(date) as d FROM daily_spend").get() as { + d: string | null; + }; if (!latestDate.d) return anomalies; const targetDate = latestDate.d; - const todayStats = db + const todaySpend = db .prepare( - `SELECT email, agent_requests, usage_based_reqs, most_used_model - FROM daily_usage - WHERE date = ? AND is_active = 1`, + `SELECT ds.email, ds.spend_cents, + COALESCE(m.name, ds.email) as name, + COALESCE(du.most_used_model, '') as most_used_model + FROM (SELECT email, MAX(spend_cents) as spend_cents FROM daily_spend WHERE date = ? GROUP BY email) ds + LEFT JOIN members m ON ds.email = m.email + LEFT JOIN daily_usage du ON ds.email = du.email AND du.date = ? + WHERE ds.spend_cents > 0`, ) - .all(targetDate) as Array<{ + .all(targetDate, targetDate) as Array<{ email: string; - agent_requests: number; - usage_based_reqs: number; + spend_cents: number; + name: string; most_used_model: string; }>; - if (todayStats.length < 5) return anomalies; + if (todaySpend.length < 5) return anomalies; - const historyStats = db - .prepare( - `SELECT email, - AVG(agent_requests) as avg_requests, - COUNT(*) as days_count - FROM daily_usage - WHERE date < ? AND date >= date(?, ?) AND is_active = 1 - GROUP BY email`, - ) - .all(targetDate, targetDate, `-${windowDays} days`) as Array<{ - email: string; - avg_requests: number; - days_count: number; - }>; - - const histMap = new Map(historyStats.map((h) => [h.email, h])); - - const allRequests = todayStats.map((s) => s.agent_requests); - const teamMean = allRequests.reduce((a, b) => a + b, 0) / allRequests.length; - const teamStddev = Math.sqrt( - allRequests.reduce((sum, v) => sum + (v - teamMean) ** 2, 0) / allRequests.length, + const spendValues = todaySpend.map((s) => s.spend_cents); + const teamSpendMean = spendValues.reduce((a, b) => a + b, 0) / spendValues.length; + const teamSpendStddev = Math.sqrt( + spendValues.reduce((sum, v) => sum + (v - teamSpendMean) ** 2, 0) / spendValues.length, ); - const allUsageBased = todayStats.map((s) => s.usage_based_reqs); - const teamUsageMean = allUsageBased.reduce((a, b) => a + b, 0) / allUsageBased.length; - const teamUsageStddev = Math.sqrt( - allUsageBased.reduce((sum, v) => sum + (v - teamUsageMean) ** 2, 0) / allUsageBased.length, - ); + if (teamSpendStddev === 0) return anomalies; - for (const user of todayStats) { - const reqZ = computeZScore(user.agent_requests, teamMean, teamStddev); - if (reqZ > multiplier) { - const hist = histMap.get(user.email); - anomalies.push({ - userEmail: user.email, - type: "zscore", - severity: reqZ > multiplier * 1.5 ? "critical" : "warning", - metric: "requests", - value: user.agent_requests, - threshold: teamMean + multiplier * teamStddev, - message: `${user.agent_requests} agent requests on ${targetDate} is ${reqZ.toFixed(1)} std devs above team mean (${teamMean.toFixed(0)}) — model: ${user.most_used_model}`, - detectedAt: now, - resolvedAt: null, - alertedAt: null, - diagnosisModel: user.most_used_model || null, - diagnosisKind: null, - diagnosisDelta: hist ? user.agent_requests - hist.avg_requests : null, - }); - } + for (const user of todaySpend) { + if (user.spend_cents < MIN_DAILY_SPEND_CENTS) continue; - const usageZ = computeZScore(user.usage_based_reqs, teamUsageMean, teamUsageStddev); - if (usageZ > multiplier && reqZ <= multiplier) { + const spendZ = computeZScore(user.spend_cents, teamSpendMean, teamSpendStddev); + if (spendZ > multiplier) { + const userDollars = (user.spend_cents / 100).toFixed(2); + const meanDollars = (teamSpendMean / 100).toFixed(2); anomalies.push({ userEmail: user.email, type: "zscore", - severity: usageZ > multiplier * 1.5 ? "critical" : "warning", - metric: "usage_based", - value: user.usage_based_reqs, - threshold: teamUsageMean + multiplier * teamUsageStddev, - message: `${user.usage_based_reqs} usage-based requests on ${targetDate} is ${usageZ.toFixed(1)} std devs above team mean (${teamUsageMean.toFixed(0)})`, + severity: spendZ > multiplier * 3 ? "critical" : "warning", + metric: "spend", + value: user.spend_cents, + threshold: teamSpendMean + multiplier * teamSpendStddev, + message: `${user.name}: daily spend $${userDollars} is ${spendZ.toFixed(1)}σ above team mean ($${meanDollars}) — model: ${user.most_used_model}`, detectedAt: now, resolvedAt: null, alertedAt: null, diagnosisModel: user.most_used_model || null, diagnosisKind: null, - diagnosisDelta: null, + diagnosisDelta: user.spend_cents - teamSpendMean, }); } } diff --git a/src/lib/db.ts b/src/lib/db.ts index 6490398..88640ca 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -745,7 +745,13 @@ export function getConfig(): DetectionConfig { | undefined; if (!row) return DEFAULT_CONFIG; - return JSON.parse(row.value) as DetectionConfig; + const stored = JSON.parse(row.value) as Partial; + return { + thresholds: { ...DEFAULT_CONFIG.thresholds, ...stored.thresholds }, + zscore: { ...DEFAULT_CONFIG.zscore, ...stored.zscore }, + trends: { ...DEFAULT_CONFIG.trends, ...stored.trends }, + cronIntervalMinutes: stored.cronIntervalMinutes ?? DEFAULT_CONFIG.cronIntervalMinutes, + }; } export function saveConfig(config: DetectionConfig): void { diff --git a/src/lib/types.ts b/src/lib/types.ts index 3bca1fa..734ea83 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -329,7 +329,7 @@ export interface GroupsResponse { export type AnomalySeverity = "warning" | "critical"; export type AnomalyType = "threshold" | "zscore" | "trend"; -export type AnomalyMetric = "spend" | "requests" | "tokens" | "usage_based" | "model_shift"; +export type AnomalyMetric = "spend" | "requests" | "tokens" | "usage_based"; export interface Anomaly { id?: number; @@ -375,9 +375,9 @@ export interface DetectionConfig { windowDays: number; }; trends: { - spikeMultiplier: number; - spikeLookbackDays: number; - driftDaysAboveP75: number; + spendSpikeMultiplier: number; + spendSpikeLookbackDays: number; + cycleOutlierMultiplier: number; }; cronIntervalMinutes: number; } @@ -393,9 +393,9 @@ export const DEFAULT_CONFIG: DetectionConfig = { windowDays: 7, }, trends: { - spikeMultiplier: 3, - spikeLookbackDays: 7, - driftDaysAboveP75: 3, + spendSpikeMultiplier: 5, + spendSpikeLookbackDays: 7, + cycleOutlierMultiplier: 10, }, cronIntervalMinutes: 60, }; From e0740efeaaf120938b8e1cdf1d8054ddf49f2c4c Mon Sep 17 00:00:00 2001 From: Ofer Shapira Date: Tue, 17 Feb 2026 23:41:55 +0200 Subject: [PATCH 4/4] feat: add npm publish support with npx setup command - Add bin/create.mjs: `npx cursor-usage-tracker my-tracker` clones the repo, installs deps, copies .env.example, and prints next steps - Remove "private": true to allow npm publishing - Add "files" field to ship only the bin/ directory (keeps package tiny) - Add @semantic-release/npm for auto-publish on release - Add NPM_TOKEN to release workflow - Update README quick start with npx option Co-authored-by: Cursor --- .github/workflows/release.yml | 1 + README.md | 11 +++++++- bin/create.mjs | 52 +++++++++++++++++++++++++++++++++++ package.json | 8 +++++- 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100755 bin/create.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9903ed3..d79caf0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,3 +26,4 @@ jobs: - run: npx semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index d3ecb55..e868938 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,16 @@ Anomaly Detected ──→ Alert Sent ──→ Acknowledged ──→ Resolved | Admin API key | Cursor dashboard → Settings → Advanced → Admin API Keys | | Node.js 18+ | [nodejs.org](https://nodejs.org) | -### 1. Clone and install +### 1. Set up + +**Option A: One command** + +```bash +npx cursor-usage-tracker my-tracker +cd my-tracker +``` + +**Option B: Manual clone** ```bash git clone https://github.com/ofershap/cursor-usage-tracker.git diff --git a/bin/create.mjs b/bin/create.mjs new file mode 100755 index 0000000..92ce308 --- /dev/null +++ b/bin/create.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +import { execSync } from "node:child_process"; +import { existsSync, copyFileSync } from "node:fs"; +import { resolve, basename } from "node:path"; + +const REPO = "https://github.com/ofershap/cursor-usage-tracker.git"; +const dirName = process.argv[2] || "cursor-usage-tracker"; +const targetDir = resolve(process.cwd(), dirName); + +function run(cmd, opts = {}) { + execSync(cmd, { stdio: "inherit", ...opts }); +} + +function log(msg) { + console.log(`\n\x1b[36m${msg}\x1b[0m`); +} + +if (existsSync(targetDir)) { + console.error(`\x1b[31mDirectory "${dirName}" already exists.\x1b[0m`); + process.exit(1); +} + +log(`Cloning cursor-usage-tracker into ${dirName}...`); +run(`git clone --depth 1 ${REPO} "${targetDir}"`); + +log("Installing dependencies..."); +run("npm install", { cwd: targetDir }); + +const envExample = resolve(targetDir, ".env.example"); +const envFile = resolve(targetDir, ".env"); +if (existsSync(envExample) && !existsSync(envFile)) { + copyFileSync(envExample, envFile); +} + +log("Done! Next steps:"); +console.log(` + cd ${dirName} + + 1. Edit .env and add your CURSOR_ADMIN_API_KEY + Get it from: Cursor dashboard → Settings → Advanced → Admin API Keys + + 2. Start the dashboard: + npm run dev + + 3. Collect your first data: + npm run collect + + 4. Open http://localhost:3000 + + Docs: https://github.com/ofershap/cursor-usage-tracker +`); diff --git a/package.json b/package.json index 0551e0a..c7e8416 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "0.0.0-development", "description": "Open-source Cursor IDE usage monitoring, anomaly detection, and alerting for enterprise teams", "type": "module", - "private": true, "scripts": { "dev": "next dev", "build": "next build", @@ -37,6 +36,7 @@ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", "@semantic-release/changelog", + "@semantic-release/npm", "@semantic-release/github", [ "@semantic-release/git", @@ -50,6 +50,12 @@ ] ] }, + "bin": { + "cursor-usage-tracker": "bin/create.mjs" + }, + "files": [ + "bin/" + ], "keywords": [ "cursor", "cursor-ide",