Skip to content

Commit bdee9ee

Browse files
ofershapcursoragent
andcommitted
feat: use usage_events as primary spend source, enrich user detail page
- Fix spend discrepancy across dashboard, user detail, cron alerts, and model efficiency by preferring usage_events over stale daily_spend/spending tables (billing groups API has ~2 day retention) - Enrich user detail page: accept rate KPI, team rank KPI, power user radar axis, Tools & Features section (MCP tools + commands), model preferences breakdown - Add per-user MCP and commands collection from by-user analytics API endpoints with new DB tables and collector tasks - Default dashboard time range to 30d with localStorage persistence - Update context files with API data reliability knowledge Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1dfbe39 commit bdee9ee

13 files changed

Lines changed: 870 additions & 166 deletions

File tree

.cursor/rules/cursor-api-data-guide.mdc

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ Key concepts that affect how we interpret the data:
8888
- `included_spend_cents` = the portion covered by the plan
8989
- Actual overage = `spend_cents - included_spend_cents`
9090

91+
### Model Pricing
92+
Cursor charges at provider list prices (Anthropic, OpenAI, Google, xAI) plus a Teams/Enterprise surcharge of $0.25/1M total tokens. Max mode adds +20% on top. Auto mode has fixed blended rates. There is no public API for pricing tables — the canonical source is `cursor.com/docs/models`. Per-model token prices are NOT needed in our code because `usage_events.total_cents` already has the computed cost per request.
93+
9194
### Model Cost Drivers
9295
Model choice is the PRIMARY cost driver. The specific models change over time, but the cost principles are stable:
9396

@@ -120,12 +123,15 @@ Legacy field from the old fixed-pricing model. It's NOT the current billing mech
120123

121124
### Now Collected
122125
- Per-request token/cost data from `/teams/filtered-usage-events` - gives per-model cost breakdown per user. Stored in `usage_events` table. Collected incrementally (since last timestamp).
123-
- Command adoption from `/analytics/team/commands` - which Cursor commands people use (explain, refactor, etc.). Stored in `analytics_commands` table.
126+
- Command adoption from `/analytics/team/commands` - team-level command usage. Stored in `analytics_commands` table.
124127
- Plan mode adoption from `/analytics/team/plans` - plan mode usage by model. Stored in `analytics_plans` table.
128+
- Per-user MCP tool usage from `/analytics/by-user/mcp` - which MCP tools each user uses. Stored in `analytics_user_mcp` table.
129+
- Per-user command usage from `/analytics/by-user/commands` - which commands each user uses. Stored in `analytics_user_commands` table.
125130

126131
### Not Currently Collected (but available)
127132
- AI Code Tracking data from `/analytics/ai-code/commits` - would give us accurate AI vs human line attribution
128-
- Per-user breakdowns from `/analytics/by-user/*` endpoints (agent-edits, tabs, models, mcp, commands, plans, ask-mode, client-versions, top-file-extensions)
133+
- Per-user breakdowns from `/analytics/by-user/*` endpoints for: agent-edits, tabs, models, plans, ask-mode, client-versions, top-file-extensions (we collect mcp and commands per-user, but not these others)
134+
- Leaderboard from `/analytics/team/leaderboard` - ranks users by tab accepts and agent edits. We chose NOT to collect this because it introduces a third ranking system that conflicts with our own spend_rank and activity_rank, confusing stakeholders.
129135
- `cmdkUsages`, `subscriptionIncludedReqs`, `apiKeyReqs`, `bugbotUsages` - available in daily usage but not stored
130136
- Audit logs from `/teams/audit-logs` - login events, settings changes, security events
131137

@@ -151,3 +157,50 @@ Low accept rate could mean: picky reviewer (good), bad prompting (fixable), or w
151157
`lines_added / agent_requests`
152158

153159
Highly task-dependent. A debugging session produces 0 lines. A scaffolding task produces 500. Not a quality metric.
160+
161+
## Daily Spend Data Sources
162+
163+
`usage_events` (from `/teams/filtered-usage-events`) is the most reliable source for daily spend data. It has per-request cost (`total_cents`) with full billing cycle history and no retention window. `daily_spend` (from `/teams/groups` billing groups API) has only ~2 days retention and systematically underreports compared to `usage_events`.
164+
165+
The dashboard daily spend chart uses `usage_events` as the primary source, falling back to `daily_spend` only when the `usage_events` table is empty (e.g., a fresh install that hasn't collected events yet). The chart marks the last 2 days as "provisional" since spend data for today/yesterday may still be accumulating.
166+
167+
## Conversation Insights (Dashboard-Only)
168+
169+
The Cursor web dashboard has a "Conversation Insights" page (`cursor.com/dashboard?tab=conversation-insights`) that shows Work Type (KTLO/Feature/Bug), Intent Distribution (Write Code/Ask/Task Automation/Plan), Categories (Bug Fix/Configuration/Feature/Refactor), Task Complexity, and Prompt Specificity. This data is computed server-side from conversation content using AI analysis. There is NO API endpoint for it — it is a dashboard-only enterprise feature.
170+
171+
## Complete Analytics API Endpoint List
172+
173+
### Team-level endpoints (all collected)
174+
- `/analytics/team/dau` — daily active users (+ CLI, Cloud Agent, BugBot DAU)
175+
- `/analytics/team/models` — model usage breakdown per day
176+
- `/analytics/team/agent-edits` — diffs suggested/accepted/rejected
177+
- `/analytics/team/tabs` — tab autocomplete metrics
178+
- `/analytics/team/mcp` — MCP tool adoption
179+
- `/analytics/team/top-file-extensions` — file types
180+
- `/analytics/team/client-versions` — version distribution
181+
- `/analytics/team/commands` — command adoption
182+
- `/analytics/team/plans` — plan mode adoption
183+
- `/analytics/team/ask-mode` — ask mode adoption (not collected)
184+
- `/analytics/team/leaderboard` — user rankings by AI usage (not collected — see note above)
185+
186+
### By-user endpoints (paginated, data keyed by email)
187+
- `/analytics/by-user/mcp` — per-user MCP tool usage (collected)
188+
- `/analytics/by-user/commands` — per-user command usage (collected)
189+
- `/analytics/by-user/agent-edits` — per-user agent edits (not collected)
190+
- `/analytics/by-user/tabs` — per-user tab usage (not collected)
191+
- `/analytics/by-user/models` — per-user model usage (not collected — covered by daily_usage)
192+
- `/analytics/by-user/plans` — per-user plan mode (not collected)
193+
- `/analytics/by-user/ask-mode` — per-user ask mode (not collected)
194+
- `/analytics/by-user/client-versions` — per-user versions (not collected — covered by daily_usage)
195+
- `/analytics/by-user/top-file-extensions` — per-user file types (not collected)
196+
197+
## Critical: Billing Groups API Daily Spend Retention
198+
199+
The `/teams/groups` endpoint returns `dailySpend` per member, but this data has a **very short retention window — approximately 2 days**. Older daily spend data is dropped from the API response entirely.
200+
201+
This means:
202+
- If you don't collect at least once per day, you will permanently lose daily spend granularity for missed days
203+
- Early-day collections capture incomplete data (spend accumulates throughout the day)
204+
- The `upsertDailySpend` function uses `MAX(existing, new)` to prevent regressions from partial data overwriting complete data
205+
- Ideal collection frequency: at least twice daily (e.g. midday + end of day) to capture most of each day's spend before it falls off the API
206+
- The dashboard marks the last 2 days as "partial (API lag)" since spend data may not be fully settled yet

.cursor/rules/project-context.mdc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ Single cron endpoint `POST /api/cron` does both: collect → detect → alert in
6565

6666
## Dashboard Pages
6767

68-
- `/` — Team overview: stat cards, spend bar chart, daily spend trend, spend breakdown by user, members table with search/sort, **group filter dropdown**, time range picker (24h/3d/7d/14d/30d), billing cycle progress
68+
- `/` — Team overview: stat cards, model cost comparison table ($/request relative multipliers), daily spend trend (sourced from `usage_events` with `daily_spend` fallback, last 2 days marked provisional), spend breakdown by user, members table with search/sort, **group filter dropdown**, time range picker (24h/3d/7d/14d/30d), billing cycle progress
6969
- `/insights` — Analytics: DAU chart, model adoption, model efficiency rankings, MCP tool usage, file extensions, client versions
70-
- `/users/[email]` — Per-user: token timeline, model pie chart, feature breakdown, activity profile, anomaly history
70+
- `/users/[email]` — Per-user detail: KPI cards (cycle spend, $/req, agent reqs, accept rate, team rank), spend trend chart, usage profile radar (activity, intensity, tab usage, precision, on plan, power user), cost breakdown by model, tools & features (MCP tools + commands per user), model preferences, daily activity table, anomaly history
7171
- `/anomalies` — MTTD/MTTI/MTTR metrics, open incidents (acknowledge/resolve), anomaly table
7272
- `/settings` — Detection thresholds, **billing group management** (rename, assign, create), **HiBob CSV import** with change preview
7373

@@ -90,7 +90,7 @@ Single cron endpoint `POST /api/cron` does both: collect → detect → alert in
9090

9191
## Database Tables
9292

93-
members, daily_usage, spending, usage_events, anomalies, incidents, config, collection_log, metadata, daily_spend, billing_groups, group_members, billing_group_members, analytics_dau, analytics_model_usage, analytics_agent_edits, analytics_tabs, analytics_mcp, analytics_file_extensions, analytics_client_versions
93+
members, daily_usage, spending, usage_events, anomalies, incidents, config, collection_log, metadata, daily_spend, billing_groups, group_members, billing_group_members, analytics_dau, analytics_model_usage, analytics_agent_edits, analytics_tabs, analytics_mcp, analytics_file_extensions, analytics_client_versions, analytics_commands, analytics_plans, analytics_user_mcp, analytics_user_commands
9494

9595
## Important Caveats
9696

scripts/generate-mock-db.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,53 @@ function run() {
736736
});
737737
planTx();
738738

739+
const userMcpStmt = db.prepare(
740+
"INSERT INTO analytics_user_mcp (date, email, tool_name, server_name, usage) VALUES (?, ?, ?, ?, ?)",
741+
);
742+
const userMcpTx = db.transaction(() => {
743+
for (let d = 0; d < DAYS; d++) {
744+
const date = dateStr(DAYS - 1 - d);
745+
for (const user of userProfiles) {
746+
if (Math.random() < 0.4) continue;
747+
const toolCount =
748+
user.activityLevel === "high"
749+
? rand(3, 8)
750+
: user.activityLevel === "medium"
751+
? rand(1, 4)
752+
: rand(0, 2);
753+
const shuffled = [...MCP_TOOLS].sort(() => Math.random() - 0.5);
754+
for (let t = 0; t < Math.min(toolCount, shuffled.length); t++) {
755+
const tool = shuffled[t] as (typeof MCP_TOOLS)[number];
756+
userMcpStmt.run(date, user.email, tool.tool, tool.server, rand(1, 30));
757+
}
758+
}
759+
}
760+
});
761+
userMcpTx();
762+
763+
const userCmdStmt = db.prepare(
764+
"INSERT INTO analytics_user_commands (date, email, command_name, usage) VALUES (?, ?, ?, ?)",
765+
);
766+
const userCmdTx = db.transaction(() => {
767+
for (let d = 0; d < DAYS; d++) {
768+
const date = dateStr(DAYS - 1 - d);
769+
for (const user of userProfiles) {
770+
if (Math.random() < 0.3) continue;
771+
const cmdCount =
772+
user.activityLevel === "high"
773+
? rand(3, 6)
774+
: user.activityLevel === "medium"
775+
? rand(1, 4)
776+
: rand(0, 2);
777+
const shuffled = [...COMMANDS].sort(() => Math.random() - 0.5);
778+
for (let c = 0; c < Math.min(cmdCount, shuffled.length); c++) {
779+
userCmdStmt.run(date, user.email, shuffled[c], rand(1, 20));
780+
}
781+
}
782+
}
783+
});
784+
userCmdTx();
785+
739786
const metaStmt = db.prepare("INSERT INTO metadata (key, value, updated_at) VALUES (?, ?, ?)");
740787
metaStmt.run("cycle_start", CYCLE_START, now);
741788
metaStmt.run("cycle_end", CYCLE_END, now);
@@ -928,6 +975,19 @@ function createSchema(db: Database.Database) {
928975
collected_at TEXT NOT NULL DEFAULT (datetime('now')),
929976
PRIMARY KEY (date, model)
930977
);
978+
CREATE TABLE IF NOT EXISTS analytics_user_mcp (
979+
date TEXT NOT NULL, email TEXT NOT NULL, tool_name TEXT NOT NULL,
980+
server_name TEXT NOT NULL, usage INTEGER NOT NULL DEFAULT 0,
981+
collected_at TEXT NOT NULL DEFAULT (datetime('now')),
982+
PRIMARY KEY (date, email, tool_name, server_name)
983+
);
984+
CREATE INDEX IF NOT EXISTS idx_user_mcp_email ON analytics_user_mcp(email);
985+
CREATE TABLE IF NOT EXISTS analytics_user_commands (
986+
date TEXT NOT NULL, email TEXT NOT NULL, command_name TEXT NOT NULL,
987+
usage INTEGER NOT NULL DEFAULT 0, collected_at TEXT NOT NULL DEFAULT (datetime('now')),
988+
PRIMARY KEY (date, email, command_name)
989+
);
990+
CREATE INDEX IF NOT EXISTS idx_user_commands_email ON analytics_user_commands(email);
931991
CREATE TABLE IF NOT EXISTS metadata (
932992
key TEXT PRIMARY KEY, value TEXT NOT NULL,
933993
updated_at TEXT NOT NULL DEFAULT (datetime('now'))

src/app/api/cron/route.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,39 @@ export async function POST(request: Request) {
6565

6666
if (lastSummary !== today) {
6767
const db = getDb();
68-
const spendRow = db
69-
.prepare(
70-
`SELECT COALESCE(SUM(spend_cents), 0) as total
71-
FROM (SELECT email, MAX(spend_cents) as spend_cents FROM spending
72-
WHERE cycle_start = (SELECT MAX(cycle_start) FROM spending)
73-
GROUP BY email)`,
74-
)
75-
.get() as { total: number };
76-
77-
const topSpenders = db
78-
.prepare(
79-
`SELECT COALESCE(m.name, s.email) as name, s.spend_cents as spend
80-
FROM spending s LEFT JOIN members m ON s.email = m.email
81-
WHERE s.cycle_start = (SELECT MAX(cycle_start) FROM spending) AND s.spend_cents > 0
82-
ORDER BY s.spend_cents DESC LIMIT 5`,
83-
)
84-
.all() as Array<{ name: string; spend: number }>;
68+
const hasUE =
69+
(db.prepare("SELECT COUNT(*) as c FROM usage_events").get() as { c: number }).c > 0;
70+
71+
const spendRow = hasUE
72+
? (db
73+
.prepare(`SELECT COALESCE(ROUND(SUM(total_cents)), 0) as total FROM usage_events`)
74+
.get() as { total: number })
75+
: (db
76+
.prepare(
77+
`SELECT COALESCE(SUM(spend_cents), 0) as total
78+
FROM (SELECT email, MAX(spend_cents) as spend_cents FROM spending
79+
WHERE cycle_start = (SELECT MAX(cycle_start) FROM spending)
80+
GROUP BY email)`,
81+
)
82+
.get() as { total: number });
83+
84+
const topSpenders = hasUE
85+
? (db
86+
.prepare(
87+
`SELECT COALESCE(m.name, ue.user_email) as name, ROUND(SUM(ue.total_cents)) as spend
88+
FROM usage_events ue LEFT JOIN members m ON ue.user_email = m.email
89+
GROUP BY ue.user_email HAVING spend > 0
90+
ORDER BY spend DESC LIMIT 5`,
91+
)
92+
.all() as Array<{ name: string; spend: number }>)
93+
: (db
94+
.prepare(
95+
`SELECT COALESCE(m.name, s.email) as name, s.spend_cents as spend
96+
FROM spending s LEFT JOIN members m ON s.email = m.email
97+
WHERE s.cycle_start = (SELECT MAX(cycle_start) FROM spending) AND s.spend_cents > 0
98+
ORDER BY s.spend_cents DESC LIMIT 5`,
99+
)
100+
.all() as Array<{ name: string; spend: number }>);
85101

86102
const limitedRow = db
87103
.prepare("SELECT value FROM metadata WHERE key = 'limited_users_count'")

src/app/api/stats/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const dynamic = "force-dynamic";
55

66
export function GET(request: Request) {
77
const url = new URL(request.url);
8-
const days = parseInt(url.searchParams.get("days") ?? "7", 10);
8+
const days = parseInt(url.searchParams.get("days") ?? "30", 10);
99

1010
const dashboard = getFullDashboard(days);
1111
return NextResponse.json(dashboard);

0 commit comments

Comments
 (0)