Skip to content

Commit 594d385

Browse files
committed
feat(cursor): add daily usage and refresh dashboards
* Add daily usage collector, extractor, and table for per-user adoption metrics. * Reorganize Cursor Usage overview panels and update related Grafana dashboards. Signed-off-by: Joshua Smith <jbsmith7741@gmail.com>
1 parent 02668bf commit 594d385

16 files changed

Lines changed: 1050 additions & 186 deletions

backend/plugins/cursor/e2e/extractors_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,17 @@ func TestCursorExtractorsDataFlow(t *testing.T) {
5959
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_usage_events.csv", "_raw_cursor_usage_events")
6060
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_members.csv", "_raw_cursor_members")
6161
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_user_spend.csv", "_raw_cursor_user_spend")
62+
dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_daily_usage.csv", "_raw_cursor_daily_usage")
6263

6364
dataflowTester.FlushTabler(&models.CursorUsageEvent{})
6465
dataflowTester.FlushTabler(&models.CursorMember{})
6566
dataflowTester.FlushTabler(&models.CursorUserSpend{})
67+
dataflowTester.FlushTabler(&models.CursorDailyUsage{})
6668

6769
dataflowTester.Subtask(tasks.ExtractMembersMeta, taskData)
6870
dataflowTester.Subtask(tasks.ExtractUsageEventsMeta, taskData)
6971
dataflowTester.Subtask(tasks.ExtractUserSpendMeta, taskData)
72+
dataflowTester.Subtask(tasks.ExtractDailyUsageMeta, taskData)
7073

7174
dataflowTester.VerifyTableWithOptions(&models.CursorUsageEvent{}, e2ehelper.TableOptions{
7275
CSVRelPath: "./snapshot_tables/_tool_cursor_usage_events.csv",
@@ -82,4 +85,9 @@ func TestCursorExtractorsDataFlow(t *testing.T) {
8285
CSVRelPath: "./snapshot_tables/_tool_cursor_user_spend.csv",
8386
IgnoreTypes: []interface{}{common.NoPKModel{}},
8487
})
88+
89+
dataflowTester.VerifyTableWithOptions(&models.CursorDailyUsage{}, e2ehelper.TableOptions{
90+
CSVRelPath: "./snapshot_tables/_tool_cursor_daily_usage.csv",
91+
IgnoreTypes: []interface{}{common.NoPKModel{}},
92+
})
8593
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
id,params,data,url,input,created_at
2+
1,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""day"":""2026-07-08"",""userId"":""user_example123"",""email"":""user@example.com"",""isActive"":true,""completions"":15,""premiumRequests"":3,""agentRequests"":5,""chatRequests"":8,""composerRequests"":2,""totalTabsAccepted"":42,""totalTabsShown"":60,""usageBasedReqs"":7,""subscriptionIncludedReqs"":6,""mostUsedModel"":""composer-2.5-fast"",""clientVersion"":""0.50.3"",""linesAdded"":120,""linesDeleted"":30}",https://api.cursor.com/teams/daily-usage-data,null,2026-07-09 17:14:19.000
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
connection_id,scope_id,user_id,usage_date,email,is_active,completions,premium_requests,agent_requests,chat_requests,composer_requests,tabs_accepted,tabs_shown,usage_based_reqs,subscription_included_reqs,most_used_model,client_version,lines_added,lines_deleted
2+
1,team,user_example123,2026-07-08T00:00:00.000+00:00,user@example.com,1,15,3,5,8,2,42,60,7,6,composer-2.5-fast,0.50.3,120,30
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 models
19+
20+
import (
21+
"time"
22+
23+
"github.com/apache/incubator-devlake/core/models/common"
24+
)
25+
26+
// CursorDailyUsage stores per-user per-day adoption metrics from POST /teams/daily-usage-data.
27+
type CursorDailyUsage struct {
28+
ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"`
29+
ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"`
30+
UserId string `gorm:"primaryKey;type:varchar(255)" json:"userId"`
31+
UsageDate time.Time `gorm:"primaryKey" json:"usageDate"`
32+
33+
Email string `json:"email" gorm:"type:varchar(255);index"`
34+
IsActive bool `json:"isActive"`
35+
Completions int `json:"completions"`
36+
PremiumRequests int `json:"premiumRequests"`
37+
AgentRequests int `json:"agentRequests"`
38+
ChatRequests int `json:"chatRequests"`
39+
ComposerRequests int `json:"composerRequests"`
40+
TabsAccepted int `json:"tabsAccepted"`
41+
TabsShown int `json:"tabsShown"`
42+
UsageBasedReqs int `json:"usageBasedReqs"`
43+
SubscriptionIncludedReqs int `json:"subscriptionIncludedReqs"`
44+
MostUsedModel string `json:"mostUsedModel" gorm:"type:varchar(255)"`
45+
ClientVersion string `json:"clientVersion" gorm:"type:varchar(100)"`
46+
LinesAdded int `json:"linesAdded"`
47+
LinesDeleted int `json:"linesDeleted"`
48+
49+
common.NoPKModel
50+
}
51+
52+
func (CursorDailyUsage) TableName() string {
53+
return "_tool_cursor_daily_usage"
54+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 migrationscripts
19+
20+
import (
21+
"encoding/json"
22+
"time"
23+
24+
"github.com/apache/incubator-devlake/core/context"
25+
"github.com/apache/incubator-devlake/core/errors"
26+
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
27+
"github.com/apache/incubator-devlake/helpers/migrationhelper"
28+
)
29+
30+
type addCursorDailyUsage struct{}
31+
32+
type cursorDailyUsage20260714 struct {
33+
ConnectionId uint64 `gorm:"primaryKey"`
34+
ScopeId string `gorm:"primaryKey;type:varchar(255)"`
35+
UserId string `gorm:"primaryKey;type:varchar(255)"`
36+
UsageDate time.Time `gorm:"primaryKey"`
37+
Email string `gorm:"type:varchar(255);index"`
38+
IsActive bool
39+
Completions int
40+
PremiumRequests int
41+
AgentRequests int
42+
ChatRequests int
43+
ComposerRequests int
44+
TabsAccepted int
45+
TabsShown int
46+
UsageBasedReqs int
47+
SubscriptionIncludedReqs int
48+
MostUsedModel string `gorm:"type:varchar(255)"`
49+
ClientVersion string `gorm:"type:varchar(100)"`
50+
LinesAdded int
51+
LinesDeleted int
52+
archived.NoPKModel
53+
}
54+
55+
func (cursorDailyUsage20260714) TableName() string { return "_tool_cursor_daily_usage" }
56+
57+
type cursorRawDailyUsage20260714 struct {
58+
ID uint64 `gorm:"primaryKey"`
59+
Params string `gorm:"type:varchar(255);index"`
60+
Data []byte
61+
Url string
62+
Input json.RawMessage `gorm:"type:json"`
63+
CreatedAt time.Time `gorm:"index"`
64+
}
65+
66+
func (cursorRawDailyUsage20260714) TableName() string { return "_raw_cursor_daily_usage" }
67+
68+
func (script *addCursorDailyUsage) Up(basicRes context.BasicRes) errors.Error {
69+
return migrationhelper.AutoMigrateTables(
70+
basicRes,
71+
&cursorDailyUsage20260714{},
72+
&cursorRawDailyUsage20260714{},
73+
)
74+
}
75+
76+
func (*addCursorDailyUsage) Version() uint64 { return 20260714000000 }
77+
78+
func (*addCursorDailyUsage) Name() string { return "cursor add daily usage tables" }

backend/plugins/cursor/models/migrationscripts/register.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ func All() []plugin.MigrationScript {
2424
return []plugin.MigrationScript{
2525
new(addCursorInitialTables),
2626
new(changeCursorRequestsCostsToFloat),
27+
new(addCursorDailyUsage),
2728
}
2829
}

backend/plugins/cursor/models/models.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ func GetTablesInfo() []dal.Tabler {
2828
&CursorUsageEvent{},
2929
&CursorUserSpend{},
3030
&CursorMember{},
31+
&CursorDailyUsage{},
3132
}
3233
}

backend/plugins/cursor/tasks/collector_utils.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,12 @@ const (
3636
rawUsageEventsTable = "cursor_usage_events"
3737
rawUserSpendTable = "cursor_user_spend"
3838
rawMembersTable = "cursor_members"
39+
rawDailyUsageTable = "cursor_daily_usage"
3940

4041
cursorApiPageSize = 100
4142
cursorInitialBackfillDays = 90
43+
// cursorDailyUsageMaxDays is the maximum span allowed by POST /teams/daily-usage-data.
44+
cursorDailyUsageMaxDays = 30
4245
)
4346

4447
type cursorRawParams struct {
@@ -92,6 +95,34 @@ func computeUsageTimeRangeMs(since *time.Time, now time.Time) (int64, int64) {
9295
return start.UnixMilli(), end.UnixMilli()
9396
}
9497

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).
100+
func splitDailyUsageTimeRangeMs(startMs, endMs int64, maxDays int) []cursorTimeRangeInput {
101+
if startMs >= endMs || maxDays <= 0 {
102+
return nil
103+
}
104+
maxDuration := time.Duration(maxDays) * 24 * time.Hour
105+
chunkStart := time.UnixMilli(startMs).UTC()
106+
end := time.UnixMilli(endMs).UTC()
107+
108+
var chunks []cursorTimeRangeInput
109+
for chunkStart.Before(end) {
110+
chunkEnd := chunkStart.Add(maxDuration)
111+
if chunkEnd.After(end) {
112+
chunkEnd = end
113+
}
114+
chunks = append(chunks, cursorTimeRangeInput{
115+
StartDateMs: chunkStart.UnixMilli(),
116+
EndDateMs: chunkEnd.UnixMilli(),
117+
})
118+
if !chunkEnd.Before(end) {
119+
break
120+
}
121+
chunkStart = chunkEnd.Add(time.Millisecond)
122+
}
123+
return chunks
124+
}
125+
95126
func parseUsageEventsResponse(res *http.Response) ([]json.RawMessage, errors.Error) {
96127
body, err := readResponseBody(res)
97128
if err != nil {
@@ -120,6 +151,20 @@ func parseSpendMembersResponse(res *http.Response) ([]json.RawMessage, errors.Er
120151
return response.TeamMemberSpend, nil
121152
}
122153

154+
func parseDailyUsageResponse(res *http.Response) ([]json.RawMessage, errors.Error) {
155+
body, err := readResponseBody(res)
156+
if err != nil {
157+
return nil, err
158+
}
159+
var response struct {
160+
Data []json.RawMessage `json:"data"`
161+
}
162+
if jsonErr := json.Unmarshal(body, &response); jsonErr != nil {
163+
return nil, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode daily usage response")
164+
}
165+
return response.Data, nil
166+
}
167+
123168
func parseMembersResponse(res *http.Response) ([]json.RawMessage, errors.Error) {
124169
body, err := readResponseBody(res)
125170
if err != nil {

backend/plugins/cursor/tasks/collector_utils_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package tasks
2020
import (
2121
"encoding/json"
2222
"testing"
23+
"time"
2324

2425
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
2526
"github.com/apache/incubator-devlake/plugins/cursor/models"
@@ -49,3 +50,48 @@ func TestRawParamsFromTaskDataIncludesEndpoint(t *testing.T) {
4950
t.Fatalf("raw params mismatch:\n got: %s\nwant: %s", encoded, expected)
5051
}
5152
}
53+
54+
func TestSplitDailyUsageTimeRangeMsChunksLongRanges(t *testing.T) {
55+
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
56+
end := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
57+
chunks := splitDailyUsageTimeRangeMs(start.UnixMilli(), end.UnixMilli(), cursorDailyUsageMaxDays)
58+
59+
if len(chunks) != 4 {
60+
t.Fatalf("expected 4 chunks for ~90-day span, got %d", len(chunks))
61+
}
62+
if chunks[0].StartDateMs != start.UnixMilli() {
63+
t.Fatalf("first chunk start mismatch: got %d want %d", chunks[0].StartDateMs, start.UnixMilli())
64+
}
65+
if chunks[len(chunks)-1].EndDateMs != end.UnixMilli() {
66+
t.Fatalf("last chunk end mismatch: got %d want %d", chunks[len(chunks)-1].EndDateMs, end.UnixMilli())
67+
}
68+
for i := 1; i < len(chunks); i++ {
69+
if chunks[i].StartDateMs <= chunks[i-1].StartDateMs {
70+
t.Fatalf("chunk %d does not advance start time", i)
71+
}
72+
maxSpan := time.Duration(cursorDailyUsageMaxDays) * 24 * time.Hour
73+
span := time.UnixMilli(chunks[i].StartDateMs).Sub(time.UnixMilli(chunks[i-1].StartDateMs))
74+
if span > maxSpan+time.Millisecond {
75+
t.Fatalf("gap between chunk %d and %d exceeds %d days", i-1, i, cursorDailyUsageMaxDays)
76+
}
77+
}
78+
}
79+
80+
func TestSplitDailyUsageTimeRangeMsEmptyRange(t *testing.T) {
81+
if chunks := splitDailyUsageTimeRangeMs(100, 100, cursorDailyUsageMaxDays); len(chunks) != 0 {
82+
t.Fatalf("expected no chunks for empty range, got %d", len(chunks))
83+
}
84+
}
85+
86+
func TestDailyUsagePostBodyUsesEpochMilliseconds(t *testing.T) {
87+
body := dailyUsagePostBody(&helper.RequestData{
88+
Input: cursorTimeRangeInput{StartDateMs: 1710720000000, EndDateMs: 1710892800000},
89+
Pager: &helper.Pager{Page: 1, Size: 100},
90+
})
91+
if body["startDate"] != int64(1710720000000) {
92+
t.Fatalf("startDate should be epoch ms int64, got %#v", body["startDate"])
93+
}
94+
if body["endDate"] != int64(1710892800000) {
95+
t.Fatalf("endDate should be epoch ms int64, got %#v", body["endDate"])
96+
}
97+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
"net/http"
22+
"time"
23+
24+
"github.com/apache/incubator-devlake/core/errors"
25+
"github.com/apache/incubator-devlake/core/plugin"
26+
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
27+
)
28+
29+
func newDailyUsageDateRangeIterator(since *time.Time) *helper.QueueIterator {
30+
startMs, endMs := computeUsageTimeRangeMs(since, time.Now().UTC())
31+
iter := helper.NewQueueIterator()
32+
for _, chunk := range splitDailyUsageTimeRangeMs(startMs, endMs, cursorDailyUsageMaxDays) {
33+
iter.Push(chunk)
34+
}
35+
return iter
36+
}
37+
38+
func dailyUsagePostBody(reqData *helper.RequestData) map[string]interface{} {
39+
input := reqData.Input.(cursorTimeRangeInput)
40+
page := 1
41+
if state, ok := reqData.CustomData.(cursorPageState); ok && state.Page > 0 {
42+
page = state.Page
43+
} else if reqData.Pager.Page > 0 {
44+
page = reqData.Pager.Page
45+
}
46+
return map[string]interface{}{
47+
"startDate": input.StartDateMs,
48+
"endDate": input.EndDateMs,
49+
"page": page,
50+
"pageSize": reqData.Pager.Size,
51+
}
52+
}
53+
54+
// CollectDailyUsage collects per-user per-day adoption metrics from POST /teams/daily-usage-data.
55+
func CollectDailyUsage(taskCtx plugin.SubTaskContext) errors.Error {
56+
data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData)
57+
if !ok {
58+
return errors.Default.New("task data is not CursorTaskData")
59+
}
60+
apiClient, err := CreateApiClient(taskCtx.TaskContext(), data.Connection)
61+
if err != nil {
62+
return err
63+
}
64+
65+
rawArgs := helper.RawDataSubTaskArgs{
66+
Ctx: taskCtx,
67+
Table: rawDailyUsageTable,
68+
Options: rawParamsFromTaskData(data),
69+
}
70+
71+
collector, err := helper.NewStatefulApiCollector(rawArgs)
72+
if err != nil {
73+
return err
74+
}
75+
76+
err = collector.InitCollector(helper.ApiCollectorArgs{
77+
ApiClient: apiClient,
78+
Input: newDailyUsageDateRangeIterator(collector.GetSince()),
79+
Method: http.MethodPost,
80+
UrlTemplate: "teams/daily-usage-data",
81+
PageSize: cursorApiPageSize,
82+
RequestBody: dailyUsagePostBody,
83+
GetNextPageCustomData: nextPostPage,
84+
ResponseParser: parseDailyUsageResponse,
85+
})
86+
if err != nil {
87+
return err
88+
}
89+
90+
return collector.Execute()
91+
}

0 commit comments

Comments
 (0)