Skip to content

Commit 1759930

Browse files
committed
feat(cursor): add stateful extract and docs
* Switch usage events and daily usage extractors to StatefulApiExtractor with versioned one-time full re-extract. * Chunk filtered-usage-events into 30-day ranges and add plugin README. Signed-off-by: Joshua Smith <jbsmith7741@gmail.com>
1 parent 634128b commit 1759930

7 files changed

Lines changed: 233 additions & 34 deletions

File tree

backend/plugins/cursor/README.md

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
-->
17+
# Cursor Plugin (Usage & Cost)
18+
19+
This plugin ingests **Cursor team usage, billing, and adoption metrics** from the [Cursor Admin API](https://cursor.com/docs/account/teams/admin-api) and stores them in DevLake tool-layer tables for Grafana dashboards and SQL analysis.
20+
21+
It follows the same structure and patterns as other DevLake AI usage plugins (notably `backend/plugins/gh-copilot`).
22+
23+
## What it collects
24+
25+
**Cursor Admin API endpoints:**
26+
27+
| Endpoint | Method | Data |
28+
|----------|--------|------|
29+
| `/teams/members` | GET | Team roster |
30+
| `/teams/spend` | POST | Per-user billing cycle spend |
31+
| `/teams/filtered-usage-events` | POST | Event-level usage and charges |
32+
| `/teams/daily-usage-data` | POST | Per-user per-day adoption metrics |
33+
34+
**Stored data (tool layer):**
35+
36+
| Table | Description |
37+
|-------|-------------|
38+
| `_tool_cursor_members` | Team member roster (email, name, role) |
39+
| `_tool_cursor_usage_events` | Billable usage events with model, tokens, and charged amounts |
40+
| `_tool_cursor_user_spend` | Per-user spend for the current billing cycle (on-demand and included) |
41+
| `_tool_cursor_daily_usage` | Daily adoption metrics: completions, requests by feature, tab acceptance, line edits |
42+
43+
Data is collected in the **Raw → Tool** layers only. There is no domain-layer converter in this plugin; Grafana dashboards query `_tool_cursor_*` tables directly.
44+
45+
## Data flow
46+
47+
```mermaid
48+
flowchart LR
49+
API[Cursor Admin API]
50+
RAW[(Raw tables\n_raw_cursor_*)]
51+
TOOL[(Tool tables\n_tool_cursor_*)]
52+
GRAF[Grafana Dashboards]
53+
54+
API --> RAW --> TOOL --> GRAF
55+
```
56+
57+
**Pipeline subtasks (in order):**
58+
59+
1. `collectMembers``extractMembers`
60+
2. `collectUsageEvents``extractUsageEvents`
61+
3. `collectUserSpend``extractUserSpend`
62+
4. `collectDailyUsage``extractDailyUsage`
63+
64+
## Repository layout
65+
66+
- `api/` — REST layer for connections, scopes, and scope configs
67+
- `impl/` — plugin meta, blueprint v200, connection helpers
68+
- `models/` — tool-layer models and migration scripts
69+
- `tasks/` — collectors, extractors, and pipeline registration
70+
- `service/` — connection test logic (Admin API permission probes)
71+
- `e2e/` — E2E fixtures and golden CSV assertions
72+
73+
## Setup
74+
75+
### Prerequisites
76+
77+
- A Cursor **Team or Business** plan with Admin API access
78+
- A **Team Admin API key** from the Cursor dashboard (Dashboard → API Keys)
79+
- Do **not** use a User API key from Settings → Integrations — user keys cannot access `/teams/*` endpoints
80+
81+
Authentication uses HTTP Basic auth: the API key as username with an empty password.
82+
83+
### 1) Create a connection
84+
85+
1. DevLake UI → **Connections → Add Connection → Cursor**
86+
2. Fill in:
87+
- **Name**: e.g. `Cursor Production Team`
88+
- **Endpoint**: defaults to `https://api.cursor.com`
89+
- **Token**: Team Admin API key
90+
- **Rate Limit**: defaults to 1,200 requests/hour (Cursor documents 20 requests/minute)
91+
3. Click **Test Connection**. DevLake probes `/teams/members`, `/teams/spend`, and `/teams/filtered-usage-events` and reports which endpoints the key can access.
92+
4. Save the connection.
93+
94+
When updating an existing connection, omit the token field to keep the encrypted value already stored in DevLake.
95+
96+
### 2) Add a scope
97+
98+
Cursor data is team-level. Add a **Team** scope for the connection. The default scope ID is `team`.
99+
100+
### 3) Create a blueprint
101+
102+
Use a blueprint plan like:
103+
104+
```json
105+
[
106+
[
107+
{
108+
"plugin": "cursor",
109+
"options": {
110+
"connectionId": 1,
111+
"scopeId": "team"
112+
}
113+
}
114+
]
115+
]
116+
```
117+
118+
Run the blueprint on a daily schedule to keep usage and cost data current.
119+
120+
### Collection behavior
121+
122+
- **Initial backfill**: usage events and daily usage collect up to **90 days** of history on the first run.
123+
- **Incremental runs**: subsequent runs use the pipeline sync policy (`TimeAfter`) to collect only new data.
124+
- **Date range chunking**: both `/teams/daily-usage-data` and `/teams/filtered-usage-events` requests are split into **30-day** chunks (API limit for daily usage; applied to usage events for resilience).
125+
- **Extract**: `extractUsageEvents` and `extractDailyUsage` use **StatefulApiExtractor** (incremental by default). A config version bump triggers a one-time full re-extract after upgrade.
126+
- **Rate limiting**: collectors honor `Retry-After` response headers and respect the configured `rateLimitPerHour`.
127+
128+
## Dashboards
129+
130+
Grafana dashboard JSON lives under `grafana/dashboards/mysql/`:
131+
132+
| Dashboard | File | UID |
133+
|-----------|------|-----|
134+
| Cursor Usage & Cost | `cursor-usage.json` | `cursor_usage` |
135+
| AI Cost Efficiency (Cursor panels) | `ai-cost-efficiency.json` ||
136+
| Multi-AI Comparison (Cursor panels) | `multi-ai-comparison.json` ||
137+
138+
See `grafana/dashboards/mysql/CursorREADME.md` for dashboard prerequisites, variables, and panel descriptions.
139+
140+
## Error handling
141+
142+
| Symptom | Likely cause |
143+
|---------|--------------|
144+
| **401 Unauthorized** on test connection | Invalid API key, or a User API key instead of a Team Admin key |
145+
| **403 Forbidden** on spend or usage events | Key lacks permission for that Admin API endpoint |
146+
| **429 Too Many Requests** | Rate limit exceeded — lower `rateLimitPerHour` or wait for `Retry-After` |
147+
| Empty usage events after successful run | Selected time range has no billable events, or sync policy excludes the date range |
148+
| Reconciliation delta on dashboard | Expected when comparing event-level charges to billing-cycle spend snapshots; see dashboard notes |
149+
150+
Tokens are sanitized before persisting. Connection test results include a `permissions` object showing which Admin API endpoints succeeded.
151+
152+
## Limitations
153+
154+
- **Team/Business Admin API only** — Enterprise-only Analytics API endpoints (`/analytics/*`) are not collected in this plugin.
155+
- **Tool layer only** — no domain-layer tables; cross-plugin joins (Jira, GitHub PRs, etc.) are done in Grafana SQL or separate tooling.
156+
- **Team-level scope** — one scope per connection represents the whole team; per-team multi-tenant collection is not supported.
157+
- **Beta** — the plugin is marked beta in Config UI while the Admin API surface continues to evolve.
158+
159+
## Testing
160+
161+
```sh
162+
# Unit tests
163+
cd backend && go test ./plugins/cursor/...
164+
165+
# E2E (requires E2E_DB_URL)
166+
make e2e-test
167+
```
168+
169+
E2E fixtures live in `backend/plugins/cursor/e2e/raw_tables/` and `e2e/snapshot_tables/`.

backend/plugins/cursor/tasks/collector_utils.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ func computeUsageTimeRangeMs(since *time.Time, now time.Time) (int64, int64) {
9595
return start.UnixMilli(), end.UnixMilli()
9696
}
9797

98-
// splitDailyUsageTimeRangeMs splits [startMs, endMs] into chunks of at most maxDays for
99-
// POST /teams/daily-usage-data (API limit: date range cannot exceed 30 days).
98+
// splitDailyUsageTimeRangeMs splits [startMs, endMs] into chunks of at most maxDays.
99+
// Used by daily-usage-data (30-day API limit) and filtered-usage-events collectors.
100100
func splitDailyUsageTimeRangeMs(startMs, endMs int64, maxDays int) []cursorTimeRangeInput {
101101
if startMs >= endMs || maxDays <= 0 {
102102
return nil

backend/plugins/cursor/tasks/collector_utils_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ func TestSplitDailyUsageTimeRangeMsEmptyRange(t *testing.T) {
8383
}
8484
}
8585

86+
func TestNewUsageEventsDateRangeIteratorChunksLongRanges(t *testing.T) {
87+
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
88+
end := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
89+
chunks := splitDailyUsageTimeRangeMs(start.UnixMilli(), end.UnixMilli(), cursorDailyUsageMaxDays)
90+
91+
if len(chunks) != 4 {
92+
t.Fatalf("expected 4 chunks for ~90-day usage-events span, got %d", len(chunks))
93+
}
94+
}
95+
8696
func TestDailyUsagePostBodyUsesEpochMilliseconds(t *testing.T) {
8797
body := dailyUsagePostBody(&helper.RequestData{
8898
Input: cursorTimeRangeInput{StartDateMs: 1710720000000, EndDateMs: 1710892800000},

backend/plugins/cursor/tasks/daily_usage_extractor.go

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ limitations under the License.
1818
package tasks
1919

2020
import (
21-
"encoding/json"
2221
"strings"
2322
"time"
2423

@@ -60,18 +59,9 @@ func ExtractDailyUsage(taskCtx plugin.SubTaskContext) errors.Error {
6059
return errors.Default.New("task data is not CursorTaskData")
6160
}
6261

63-
extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
64-
RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
65-
Ctx: taskCtx,
66-
Table: rawDailyUsageTable,
67-
Options: rawParamsFromTaskData(data),
68-
},
69-
Extract: func(row *helper.RawData) ([]interface{}, errors.Error) {
70-
var record dailyUsageRecord
71-
if err := errors.Convert(json.Unmarshal(row.Data, &record)); err != nil {
72-
return nil, err
73-
}
74-
62+
extractor, err := helper.NewStatefulApiExtractor(&helper.StatefulApiExtractorArgs[dailyUsageRecord]{
63+
SubtaskCommonArgs: cursorSubtaskCommonArgs(taskCtx, data, rawDailyUsageTable),
64+
Extract: func(record *dailyUsageRecord, _ *helper.RawData) ([]any, errors.Error) {
7565
userId := strings.TrimSpace(record.UserId)
7666
if userId == "" {
7767
userId = strings.TrimSpace(record.Email)
@@ -113,7 +103,7 @@ func ExtractDailyUsage(taskCtx plugin.SubTaskContext) errors.Error {
113103
LinesAdded: record.AcceptedLinesAdded,
114104
LinesDeleted: record.AcceptedLinesDeleted,
115105
}
116-
return []interface{}{usage}, nil
106+
return []any{usage}, nil
117107
},
118108
})
119109
if err != nil {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package tasks
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/plugin"
22+
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
23+
)
24+
25+
// cursorExtractorVersion bumps when extract logic changes in a way that requires
26+
// a one-time full re-extract (SubtaskStateManager compares SubtaskConfig).
27+
const cursorExtractorVersion = 2
28+
29+
func cursorSubtaskCommonArgs(taskCtx plugin.SubTaskContext, data *CursorTaskData, rawTable string) *helper.SubtaskCommonArgs {
30+
return &helper.SubtaskCommonArgs{
31+
SubTaskContext: taskCtx,
32+
Table: rawTable,
33+
Params: rawParamsFromTaskData(data),
34+
SubtaskConfig: map[string]any{
35+
"extractorVersion": cursorExtractorVersion,
36+
},
37+
}
38+
}

backend/plugins/cursor/tasks/usage_events_collector.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ import (
2626
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
2727
)
2828

29-
func newTimeRangeIterator(since *time.Time) *helper.QueueIterator {
30-
iter := helper.NewQueueIterator()
29+
func newUsageEventsDateRangeIterator(since *time.Time) *helper.QueueIterator {
3130
startMs, endMs := computeUsageTimeRangeMs(since, time.Now().UTC())
32-
iter.Push(cursorTimeRangeInput{StartDateMs: startMs, EndDateMs: endMs})
31+
iter := helper.NewQueueIterator()
32+
for _, chunk := range splitDailyUsageTimeRangeMs(startMs, endMs, cursorDailyUsageMaxDays) {
33+
iter.Push(chunk)
34+
}
3335
return iter
3436
}
3537

@@ -90,7 +92,7 @@ func CollectUsageEvents(taskCtx plugin.SubTaskContext) errors.Error {
9092

9193
err = collector.InitCollector(helper.ApiCollectorArgs{
9294
ApiClient: apiClient,
93-
Input: newTimeRangeIterator(collector.GetSince()),
95+
Input: newUsageEventsDateRangeIterator(collector.GetSince()),
9496
Method: http.MethodPost,
9597
UrlTemplate: "teams/filtered-usage-events",
9698
PageSize: cursorApiPageSize,

backend/plugins/cursor/tasks/usage_events_extractor.go

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ limitations under the License.
1818
package tasks
1919

2020
import (
21-
"encoding/json"
2221
"strings"
2322

2423
"github.com/apache/incubator-devlake/core/errors"
@@ -60,18 +59,9 @@ func ExtractUsageEvents(taskCtx plugin.SubTaskContext) errors.Error {
6059
return errors.Default.New("task data is not CursorTaskData")
6160
}
6261

63-
extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
64-
RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
65-
Ctx: taskCtx,
66-
Table: rawUsageEventsTable,
67-
Options: rawParamsFromTaskData(data),
68-
},
69-
Extract: func(row *helper.RawData) ([]interface{}, errors.Error) {
70-
var record usageEventRecord
71-
if err := errors.Convert(json.Unmarshal(row.Data, &record)); err != nil {
72-
return nil, err
73-
}
74-
62+
extractor, err := helper.NewStatefulApiExtractor(&helper.StatefulApiExtractorArgs[usageEventRecord]{
63+
SubtaskCommonArgs: cursorSubtaskCommonArgs(taskCtx, data, rawUsageEventsTable),
64+
Extract: func(record *usageEventRecord, _ *helper.RawData) ([]any, errors.Error) {
7565
eventTime, err := parseEventTimestampMs(record.Timestamp)
7666
if err != nil {
7767
return nil, err
@@ -108,7 +98,7 @@ func ExtractUsageEvents(taskCtx plugin.SubTaskContext) errors.Error {
10898
event.CacheWriteTokens = record.TokenUsage.CacheWriteTokens
10999
event.TotalCents = record.TokenUsage.TotalCents
110100
}
111-
return []interface{}{event}, nil
101+
return []any{event}, nil
112102
},
113103
})
114104
if err != nil {

0 commit comments

Comments
 (0)