diff --git a/backend/plugins/cursor/README.md b/backend/plugins/cursor/README.md
new file mode 100644
index 00000000000..38a12307c82
--- /dev/null
+++ b/backend/plugins/cursor/README.md
@@ -0,0 +1,188 @@
+
+# Cursor Plugin (Usage & Cost)
+
+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.
+
+It follows the same structure and patterns as other DevLake AI usage plugins (notably `backend/plugins/gh-copilot`).
+
+## What it collects
+
+**Cursor Admin API endpoints:**
+
+| Endpoint | Method | Data |
+|----------|--------|------|
+| `/teams/members` | GET | Team roster |
+| `/teams/spend` | POST | Per-user billing cycle spend |
+| `/teams/filtered-usage-events` | POST | Event-level usage and charges |
+| `/teams/daily-usage-data` | POST | Per-user per-day adoption metrics |
+
+**Stored data (tool layer):**
+
+| Table | Description |
+|-------|-------------|
+| `_tool_cursor_members` | Team member roster (email, name, role) |
+| `_tool_cursor_usage_events` | Billable usage events with model, tokens, and charged amounts |
+| `_tool_cursor_user_spend` | Per-user spend for the current billing cycle (on-demand and included) |
+| `_tool_cursor_daily_usage` | Daily adoption metrics: completions, requests by feature, tab acceptance, line edits |
+
+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.
+
+## Data flow
+
+```mermaid
+flowchart LR
+ API[Cursor Admin API]
+ RAW[(Raw tables\n_raw_cursor_*)]
+ TOOL[(Tool tables\n_tool_cursor_*)]
+ GRAF[Grafana Dashboards]
+
+ API --> RAW --> TOOL --> GRAF
+```
+
+**Pipeline subtasks (in order):**
+
+1. `collectMembers` → `extractMembers`
+2. `collectUsageEvents` → `extractUsageEvents`
+3. `collectUserSpend` → `extractUserSpend`
+4. `collectDailyUsage` → `extractDailyUsage`
+
+## Repository layout
+
+- `api/` — REST layer for connections, scopes, and scope configs
+- `impl/` — plugin meta, blueprint v200, connection helpers
+- `models/` — tool-layer models and migration scripts
+- `tasks/` — collectors, extractors, and pipeline registration
+- `service/` — connection test logic (Admin API permission probes)
+- `e2e/` — E2E fixtures and golden CSV assertions
+
+## Setup
+
+### Prerequisites
+
+- A Cursor **Team or Business** plan with Admin API access
+- A **Team Admin API key** from the Cursor dashboard (Dashboard → API Keys)
+- Do **not** use a User API key from Settings → Integrations — user keys cannot access `/teams/*` endpoints
+
+Authentication uses HTTP Basic auth: the API key as username with an empty password.
+
+### 1) Create a connection
+
+1. DevLake UI → **Connections → Add Connection → Cursor**
+2. Fill in:
+ - **Name**: e.g. `Cursor Production Team`
+ - **Endpoint**: defaults to `https://api.cursor.com`
+ - **Token**: Team Admin API key
+ - **Rate Limit**: defaults to 1,200 requests/hour (Cursor documents 20 requests/minute)
+3. Click **Test Connection**. DevLake probes `/teams/members`, `/teams/spend`, and `/teams/filtered-usage-events` and reports which endpoints the key can access.
+4. Save the connection.
+
+When updating an existing connection, omit the token field to keep the encrypted value already stored in DevLake.
+
+### 2) Add a scope
+
+Cursor data is team-level. Add a **Team** scope for the connection. The default scope ID is `team`.
+
+### 3) Create a blueprint
+
+Use a blueprint plan like:
+
+```json
+[
+ [
+ {
+ "plugin": "cursor",
+ "options": {
+ "connectionId": 1,
+ "scopeId": "team"
+ }
+ }
+ ]
+]
+```
+
+Run the blueprint on a daily schedule to keep usage and cost data current.
+
+### Collection behavior
+
+- **Initial backfill**: usage events and daily usage collect up to **90 days** of history on the first run.
+- **Incremental runs**: subsequent runs use the pipeline sync policy (`TimeAfter`) to collect only new data.
+- **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).
+- **Extract**: `extractUsageEvents` and `extractDailyUsage` use **StatefulApiExtractor** (incremental by default). A config version bump triggers a one-time full re-extract after upgrade.
+- **Rate limiting**: collectors honor `Retry-After` response headers and respect the configured `rateLimitPerHour`.
+
+## Dashboards
+
+Grafana dashboard JSON lives under `grafana/dashboards/mysql/`:
+
+| Dashboard | File | UID |
+|-----------|------|-----|
+| Cursor Usage & Cost | `cursor-usage.json` | `cursor_usage` |
+| AI Cost Efficiency (Cursor panels) | `ai-cost-efficiency.json` | — |
+| Multi-AI Comparison (Cursor panels) | `multi-ai-comparison.json` | — |
+
+## Error handling
+
+| Symptom | Likely cause |
+|---------|--------------|
+| **401 Unauthorized** on test connection | Invalid API key, or a User API key instead of a Team Admin key |
+| **403 Forbidden** on spend or usage events | Key lacks permission for that Admin API endpoint |
+| **429 Too Many Requests** | Rate limit exceeded — lower `rateLimitPerHour` or wait for `Retry-After` |
+| Empty usage events after successful run | Selected time range has no billable events, or sync policy excludes the date range |
+| Reconciliation delta on dashboard | Expected when comparing event-level charges to billing-cycle spend snapshots; see dashboard notes |
+
+Tokens are sanitized before persisting. Connection test results include a `permissions` object showing which Admin API endpoints succeeded.
+
+## Not collected (Enterprise AI Code Tracking)
+
+The following endpoints from the [AI Code Tracking API](https://cursor.com/docs/account/teams/ai-code-tracking-api) are **Enterprise plan only** and are **not implemented** in this plugin (no collector, extractor, or `_tool_cursor_*` table):
+
+| Endpoint | Purpose |
+|----------|---------|
+| `GET /analytics/ai-code/commits` | Per-commit AI line attribution (TAB vs Composer vs non-AI) |
+| `GET /analytics/ai-code/changes` | Granular accepted AI changes |
+| `GET /analytics/ai-code/commits.csv` / `changes.csv` | Bulk CSV exports of the above |
+
+Team/Business Admin API keys typically receive **401** on these routes. Connection test optionally probes `GET /analytics/team/dau` (`probeEnterpriseAnalytics`) to detect Enterprise access; it does **not** ingest analytics data.
+
+**Metrics this would unlock** (shown on the Cursor native dashboard but absent from DevLake today):
+
+- AI share of **committed** code (not editor line acceptance)
+- Per-commit `commitSource`: IDE, CLI, or cloud
+- Repo name and primary-branch filters
+- TAB vs Composer line attribution on commits
+
+The **Cursor Usage** Grafana dashboard (`grafana/dashboards/mysql/cursor-usage.json`) uses Admin API proxies from `_tool_cursor_daily_usage` and `_tool_cursor_usage_events` instead; panel descriptions note where metrics are approximate.
+
+## Limitations
+
+- **Team/Business Admin API only** — Enterprise-only Analytics API endpoints (`/analytics/*`) are not collected in this plugin (see [Not collected (Enterprise AI Code Tracking)](#not-collected-enterprise-ai-code-tracking) above).
+- **Tool layer only** — no domain-layer tables; cross-plugin joins (Jira, GitHub PRs, etc.) are done in Grafana SQL or separate tooling.
+- **Team-level scope** — one scope per connection represents the whole team; per-team multi-tenant collection is not supported.
+- **Beta** — the plugin is marked beta in Config UI while the Admin API surface continues to evolve.
+
+## Testing
+
+```sh
+# Unit tests
+cd backend && go test ./plugins/cursor/...
+
+# E2E (requires E2E_DB_URL)
+make e2e-test
+```
+
+E2E fixtures live in `backend/plugins/cursor/e2e/raw_tables/` and `e2e/snapshot_tables/`.
diff --git a/backend/plugins/cursor/api/blueprint_v200.go b/backend/plugins/cursor/api/blueprint_v200.go
new file mode 100644
index 00000000000..b1431c9bdb0
--- /dev/null
+++ b/backend/plugins/cursor/api/blueprint_v200.go
@@ -0,0 +1,81 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "github.com/apache/incubator-devlake/core/errors"
+ coreModels "github.com/apache/incubator-devlake/core/models"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/helpers/srvhelper"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+ "github.com/apache/incubator-devlake/plugins/cursor/tasks"
+)
+
+// MakeDataSourcePipelinePlanV200 generates the pipeline plan for blueprint v2.0.0.
+func MakeDataSourcePipelinePlanV200(
+ subtaskMetas []plugin.SubTaskMeta,
+ connectionId uint64,
+ bpScopes []*coreModels.BlueprintScope,
+) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
+ _, err := dsHelper.ConnSrv.FindByPk(connectionId)
+ if err != nil {
+ return nil, nil, err
+ }
+ scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ plan, err := makeDataSourcePipelinePlanV200(subtaskMetas, scopeDetails)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return plan, nil, nil
+}
+
+func makeDataSourcePipelinePlanV200(
+ subtaskMetas []plugin.SubTaskMeta,
+ scopeDetails []*srvhelper.ScopeDetail[models.CursorScope, models.CursorScopeConfig],
+) (coreModels.PipelinePlan, errors.Error) {
+ plan := make(coreModels.PipelinePlan, len(scopeDetails))
+ for i, scopeDetail := range scopeDetails {
+ stage := plan[i]
+ if stage == nil {
+ stage = coreModels.PipelineStage{}
+ }
+
+ scope := scopeDetail.Scope
+ task, err := helper.MakePipelinePlanTask(
+ "cursor",
+ subtaskMetas,
+ nil,
+ tasks.CursorOptions{
+ ConnectionId: scope.ConnectionId,
+ ScopeId: scope.Id,
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ stage = append(stage, task)
+ plan[i] = stage
+ }
+ return plan, nil
+}
diff --git a/backend/plugins/cursor/api/connection.go b/backend/plugins/cursor/api/connection.go
new file mode 100644
index 00000000000..594e201f495
--- /dev/null
+++ b/backend/plugins/cursor/api/connection.go
@@ -0,0 +1,105 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "strings"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+// PostConnections creates a new Cursor connection.
+func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ connection := &models.CursorConnection{}
+ if err := helper.Decode(input.Body, connection, vld); err != nil {
+ return nil, err
+ }
+
+ connection.Normalize()
+ if err := validateConnection(connection); err != nil {
+ return nil, err
+ }
+
+ if err := connectionHelper.Create(connection, input); err != nil {
+ return nil, err
+ }
+ return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil
+}
+
+func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ connection := &models.CursorConnection{}
+ if err := connectionHelper.First(connection, input.Params); err != nil {
+ return nil, err
+ }
+ if err := (&models.CursorConnection{}).MergeFromRequest(connection, input.Body); err != nil {
+ return nil, errors.Convert(err)
+ }
+ connection.Normalize()
+ if err := validateConnection(connection); err != nil {
+ return nil, err
+ }
+ if err := connectionHelper.SaveWithCreateOrUpdate(connection); err != nil {
+ return nil, err
+ }
+ return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil
+}
+
+func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ conn := &models.CursorConnection{}
+ output, err := connectionHelper.Delete(conn, input)
+ if err != nil {
+ return output, err
+ }
+ output.Body = conn.Sanitize()
+ return output, nil
+}
+
+func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ var connections []models.CursorConnection
+ if err := connectionHelper.List(&connections); err != nil {
+ return nil, err
+ }
+ for i := range connections {
+ connections[i] = connections[i].Sanitize()
+ }
+ return &plugin.ApiResourceOutput{Body: connections}, nil
+}
+
+func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ connection := &models.CursorConnection{}
+ if err := connectionHelper.First(connection, input.Params); err != nil {
+ return nil, err
+ }
+ return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil
+}
+
+func validateConnection(connection *models.CursorConnection) errors.Error {
+ if connection == nil {
+ return errors.BadInput.New("connection is required")
+ }
+ if strings.TrimSpace(connection.Token) == "" {
+ return errors.BadInput.New("token is required")
+ }
+ if connection.RateLimitPerHour < 0 {
+ return errors.BadInput.New("rateLimitPerHour must be non-negative")
+ }
+ return nil
+}
diff --git a/backend/plugins/cursor/api/init.go b/backend/plugins/cursor/api/init.go
new file mode 100644
index 00000000000..74d219690be
--- /dev/null
+++ b/backend/plugins/cursor/api/init.go
@@ -0,0 +1,58 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "github.com/go-playground/validator/v10"
+
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+var (
+ basicRes context.BasicRes
+ vld *validator.Validate
+ connectionHelper *helper.ConnectionApiHelper
+ dsHelper *helper.DsHelper[models.CursorConnection, models.CursorScope, models.CursorScopeConfig]
+ raProxy *helper.DsRemoteApiProxyHelper[models.CursorConnection]
+ raScopeList *helper.DsRemoteApiScopeListHelper[models.CursorConnection, models.CursorScope, CursorRemotePagination]
+)
+
+// Init stores basic resources and configures shared helpers for API handlers.
+func Init(br context.BasicRes, meta plugin.PluginMeta) {
+ basicRes = br
+ vld = validator.New()
+ connectionHelper = helper.NewConnectionHelper(basicRes, vld, meta.Name())
+ dsHelper = helper.NewDataSourceHelper[
+ models.CursorConnection, models.CursorScope, models.CursorScopeConfig,
+ ](
+ basicRes,
+ meta.Name(),
+ []string{"id", "teamId"},
+ func(c models.CursorConnection) models.CursorConnection {
+ c.Normalize()
+ return c.Sanitize()
+ },
+ func(s models.CursorScope) models.CursorScope { return s },
+ nil,
+ )
+ raProxy = helper.NewDsRemoteApiProxyHelper[models.CursorConnection](dsHelper.ConnApi.ModelApiHelper)
+ raScopeList = helper.NewDsRemoteApiScopeListHelper[models.CursorConnection, models.CursorScope, CursorRemotePagination](raProxy, listCursorRemoteScopes)
+}
diff --git a/backend/plugins/cursor/api/remote_api.go b/backend/plugins/cursor/api/remote_api.go
new file mode 100644
index 00000000000..fdc63a93757
--- /dev/null
+++ b/backend/plugins/cursor/api/remote_api.go
@@ -0,0 +1,95 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "strings"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helperapi "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ dsmodels "github.com/apache/incubator-devlake/helpers/pluginhelper/api/models"
+ "github.com/apache/incubator-devlake/helpers/utils"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+// CursorRemotePagination is a placeholder for remote scope pagination.
+type CursorRemotePagination struct {
+ Page int `json:"page"`
+}
+
+func listCursorRemoteScopes(
+ _ *models.CursorConnection,
+ _ plugin.ApiClient,
+ _ string,
+ _ CursorRemotePagination,
+) (
+ children []dsmodels.DsRemoteApiScopeListEntry[models.CursorScope],
+ nextPage *CursorRemotePagination,
+ err errors.Error,
+) {
+ children = append(children, makeCursorRemoteScopeEntry())
+ return children, nil, nil
+}
+
+func makeCursorRemoteScopeEntry() dsmodels.DsRemoteApiScopeListEntry[models.CursorScope] {
+ return dsmodels.DsRemoteApiScopeListEntry[models.CursorScope]{
+ Type: helperapi.RAS_ENTRY_TYPE_SCOPE,
+ Id: models.DefaultScopeID,
+ Name: "Team",
+ FullName: "Cursor Team",
+ Data: &models.CursorScope{
+ Id: models.DefaultScopeID,
+ Name: "Team",
+ FullName: "Cursor Team",
+ },
+ }
+}
+
+// RemoteScopes lists all available scopes for this connection.
+func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return raScopeList.Get(input)
+}
+
+// SearchRemoteScopes searches scopes for this connection.
+func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ params := &dsmodels.DsRemoteApiScopeSearchParams{
+ Page: 1,
+ PageSize: 50,
+ }
+ if err := utils.DecodeMapStruct(input.Query, params, true); err != nil {
+ return nil, err
+ }
+ if err := errors.Convert(vld.Struct(params)); err != nil {
+ return nil, errors.BadInput.Wrap(err, "invalid params")
+ }
+
+ children := []dsmodels.DsRemoteApiScopeListEntry[models.CursorScope]{}
+ searchLower := strings.ToLower(strings.TrimSpace(params.Search))
+ if searchLower == "" || searchLower == "team" || searchLower == "cursor" {
+ children = append(children, makeCursorRemoteScopeEntry())
+ }
+
+ return &plugin.ApiResourceOutput{
+ Body: map[string]interface{}{
+ "children": children,
+ "page": params.Page,
+ "pageSize": params.PageSize,
+ },
+ }, nil
+}
diff --git a/backend/plugins/cursor/api/scope.go b/backend/plugins/cursor/api/scope.go
new file mode 100644
index 00000000000..ce8dea71797
--- /dev/null
+++ b/backend/plugins/cursor/api/scope.go
@@ -0,0 +1,53 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+)
+
+// GetScopeList returns all scopes for a connection.
+func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.GetPage(input)
+}
+
+// PutScopes creates or updates scopes for a connection.
+func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.PutMultiple(input)
+}
+
+// GetScope returns a single scope by id.
+func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.GetScopeDetail(input)
+}
+
+// PatchScope partially updates a scope.
+func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.Patch(input)
+}
+
+// DeleteScope removes a scope.
+func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.Delete(input)
+}
+
+// GetScopeLatestSyncState returns the latest sync state for a scope.
+func GetScopeLatestSyncState(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.GetScopeLatestSyncState(input)
+}
diff --git a/backend/plugins/cursor/api/scope_config.go b/backend/plugins/cursor/api/scope_config.go
new file mode 100644
index 00000000000..571e49e85ca
--- /dev/null
+++ b/backend/plugins/cursor/api/scope_config.go
@@ -0,0 +1,53 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+)
+
+// PostScopeConfig creates a new scope config.
+func PostScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeConfigApi.Post(input)
+}
+
+// GetScopeConfigList returns all scope configs for a connection.
+func GetScopeConfigList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeConfigApi.GetAll(input)
+}
+
+// GetScopeConfig returns a single scope config by id.
+func GetScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeConfigApi.GetDetail(input)
+}
+
+// PatchScopeConfig partially updates a scope config.
+func PatchScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeConfigApi.Patch(input)
+}
+
+// DeleteScopeConfig removes a scope config.
+func DeleteScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeConfigApi.Delete(input)
+}
+
+// GetProjectsByScopeConfig returns projects associated with a scope config.
+func GetProjectsByScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeConfigApi.GetProjectsByScopeConfig(input)
+}
diff --git a/backend/plugins/cursor/api/test_connection.go b/backend/plugins/cursor/api/test_connection.go
new file mode 100644
index 00000000000..c4220d4bca9
--- /dev/null
+++ b/backend/plugins/cursor/api/test_connection.go
@@ -0,0 +1,70 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ gocontext "context"
+ "net/http"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+ "github.com/apache/incubator-devlake/plugins/cursor/service"
+)
+
+// TestConnection validates a Cursor connection before saving it.
+func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ connection := &models.CursorConnection{}
+ if err := helper.Decode(input.Body, connection, vld); err != nil {
+ return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
+ }
+
+ connection.Normalize()
+ if err := validateConnection(connection); err != nil {
+ return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
+ }
+
+ result, err := service.TestConnection(gocontext.Background(), basicRes, connection)
+ if err != nil {
+ return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
+ }
+ return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil
+}
+
+// TestExistingConnection validates a stored Cursor connection with optional overrides.
+func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
+ connection := &models.CursorConnection{}
+ if err := connectionHelper.First(connection, input.Params); err != nil {
+ return nil, plugin.WrapTestConnectionErrResp(basicRes, errors.BadInput.Wrap(err, "find connection from db"))
+ }
+ if err := (&models.CursorConnection{}).MergeFromRequest(connection, input.Body); err != nil {
+ return nil, plugin.WrapTestConnectionErrResp(basicRes, errors.Convert(err))
+ }
+
+ connection.Normalize()
+ if err := validateConnection(connection); err != nil {
+ return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
+ }
+
+ result, err := service.TestConnection(gocontext.Background(), basicRes, connection)
+ if err != nil {
+ return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
+ }
+ return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil
+}
diff --git a/backend/plugins/cursor/cost_reconciliation.sql b/backend/plugins/cursor/cost_reconciliation.sql
new file mode 100644
index 00000000000..6ee0588dced
--- /dev/null
+++ b/backend/plugins/cursor/cost_reconciliation.sql
@@ -0,0 +1,41 @@
+-- Cursor cost reconciliation queries
+-- Run against the lake database after a full billing-cycle event backfill.
+--
+-- Note: SUM(charged_cents) from events only reconciles with /teams/spend when
+-- ALL usage events for the billing cycle are collected (paginate filtered-usage-events).
+
+-- Team-level rollup comparison
+SELECT
+ ROUND(SUM(spend_cents) / 100, 2) AS spend_usd,
+ ROUND(SUM(included_spend_cents) / 100, 2) AS included_usd,
+ ROUND(SUM(spend_cents + included_spend_cents) / 100, 2) AS total_cycle_usd
+FROM _tool_cursor_user_spend;
+
+SELECT
+ ROUND(SUM(charged_cents) / 100, 2) AS event_charged_usd,
+ ROUND(SUM(total_cents) / 100, 2) AS event_model_usd,
+ COUNT(*) AS event_count,
+ MIN(event_time) AS earliest_event,
+ MAX(event_time) AS latest_event
+FROM _tool_cursor_usage_events;
+
+-- Per-user: billing cycle spend vs event charged (since billing cycle start)
+SELECT
+ s.email,
+ ROUND(s.spend_cents / 100, 2) AS spend_usd,
+ ROUND(s.included_spend_cents / 100, 2) AS included_usd,
+ ROUND((s.spend_cents + s.included_spend_cents) / 100, 2) AS total_cycle_usd,
+ ROUND(COALESCE(SUM(e.charged_cents), 0) / 100, 2) AS event_charged_usd,
+ COUNT(e.event_id) AS event_count
+FROM _tool_cursor_user_spend s
+LEFT JOIN _tool_cursor_usage_events e
+ ON s.email = e.user_email
+ AND e.event_time >= s.billing_cycle_start
+GROUP BY s.email, s.spend_cents, s.included_spend_cents
+ORDER BY total_cycle_usd DESC;
+
+-- Users with events but no spend row (should be empty)
+SELECT DISTINCT e.user_email
+FROM _tool_cursor_usage_events e
+LEFT JOIN _tool_cursor_user_spend s ON e.user_email = s.email
+WHERE s.email IS NULL;
diff --git a/backend/plugins/cursor/cursor.go b/backend/plugins/cursor/cursor.go
new file mode 100644
index 00000000000..cd6ec904c7a
--- /dev/null
+++ b/backend/plugins/cursor/cursor.go
@@ -0,0 +1,44 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+ "github.com/apache/incubator-devlake/core/runner"
+ "github.com/apache/incubator-devlake/plugins/cursor/impl"
+ "github.com/spf13/cobra"
+)
+
+var PluginEntry impl.Cursor
+
+func main() {
+ cmd := &cobra.Command{Use: "cursor"}
+ connectionId := cmd.Flags().Uint64P("connectionId", "c", 0, "cursor connection id")
+ scopeId := cmd.Flags().StringP("scopeId", "s", "", "cursor scope id")
+ timeAfter := cmd.Flags().StringP("timeAfter", "a", "", "collect data created after the specified time")
+
+ _ = cmd.MarkFlagRequired("connectionId")
+ _ = cmd.MarkFlagRequired("scopeId")
+
+ cmd.Run = func(cmd *cobra.Command, args []string) {
+ runner.DirectRun(cmd, args, PluginEntry, map[string]interface{}{
+ "connectionId": *connectionId,
+ "scopeId": *scopeId,
+ }, *timeAfter)
+ }
+ runner.RunCmd(cmd)
+}
diff --git a/backend/plugins/cursor/e2e/extractors_test.go b/backend/plugins/cursor/e2e/extractors_test.go
new file mode 100644
index 00000000000..1dc234f164f
--- /dev/null
+++ b/backend/plugins/cursor/e2e/extractors_test.go
@@ -0,0 +1,93 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package e2e
+
+import (
+ "testing"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/config"
+ "github.com/apache/incubator-devlake/core/models/common"
+ "github.com/apache/incubator-devlake/core/runner"
+ "github.com/apache/incubator-devlake/helpers/e2ehelper"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/impl"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+ "github.com/apache/incubator-devlake/plugins/cursor/tasks"
+)
+
+func TestCursorExtractorsDataFlow(t *testing.T) {
+ cfg := config.GetConfig()
+ dbUrl := cfg.GetString("E2E_DB_URL")
+ if dbUrl == "" {
+ t.Skip("skipping e2e test: E2E_DB_URL is not set")
+ }
+ if err := runner.CheckDbConnection(dbUrl, 10*time.Second); err != nil {
+ t.Skipf("skipping e2e test: cannot connect to E2E_DB_URL: %v", err)
+ }
+
+ var cursorPlugin impl.Cursor
+ dataflowTester := e2ehelper.NewDataFlowTester(t, "cursor", cursorPlugin)
+
+ taskData := &tasks.CursorTaskData{
+ Options: &tasks.CursorOptions{
+ ConnectionId: 1,
+ ScopeId: "team",
+ },
+ Connection: &models.CursorConnection{
+ CursorConn: models.CursorConn{
+ RestConnection: helper.RestConnection{Endpoint: models.DefaultEndpoint},
+ },
+ },
+ }
+
+ dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_usage_events.csv", "_raw_cursor_usage_events")
+ dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_members.csv", "_raw_cursor_members")
+ dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_user_spend.csv", "_raw_cursor_user_spend")
+ dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_daily_usage.csv", "_raw_cursor_daily_usage")
+
+ dataflowTester.FlushTabler(&models.CursorUsageEvent{})
+ dataflowTester.FlushTabler(&models.CursorMember{})
+ dataflowTester.FlushTabler(&models.CursorUserSpend{})
+ dataflowTester.FlushTabler(&models.CursorDailyUsage{})
+
+ dataflowTester.Subtask(tasks.ExtractMembersMeta, taskData)
+ dataflowTester.Subtask(tasks.ExtractUsageEventsMeta, taskData)
+ dataflowTester.Subtask(tasks.ExtractUserSpendMeta, taskData)
+ dataflowTester.Subtask(tasks.ExtractDailyUsageMeta, taskData)
+
+ dataflowTester.VerifyTableWithOptions(&models.CursorUsageEvent{}, e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/_tool_cursor_usage_events.csv",
+ IgnoreTypes: []interface{}{common.NoPKModel{}},
+ })
+
+ dataflowTester.VerifyTableWithOptions(&models.CursorMember{}, e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/_tool_cursor_members.csv",
+ IgnoreTypes: []interface{}{common.NoPKModel{}},
+ })
+
+ dataflowTester.VerifyTableWithOptions(&models.CursorUserSpend{}, e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/_tool_cursor_user_spend.csv",
+ IgnoreTypes: []interface{}{common.NoPKModel{}},
+ })
+
+ dataflowTester.VerifyTableWithOptions(&models.CursorDailyUsage{}, e2ehelper.TableOptions{
+ CSVRelPath: "./snapshot_tables/_tool_cursor_daily_usage.csv",
+ IgnoreTypes: []interface{}{common.NoPKModel{}},
+ })
+}
diff --git a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_daily_usage.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_daily_usage.csv
new file mode 100644
index 00000000000..5150658ba1a
--- /dev/null
+++ b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_daily_usage.csv
@@ -0,0 +1,2 @@
+id,params,data,url,input,created_at
+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"",""totalLinesAdded"":150,""totalLinesDeleted"":35,""acceptedLinesAdded"":120,""acceptedLinesDeleted"":30,""totalApplies"":10,""totalAccepts"":8,""totalRejects"":2}",https://api.cursor.com/teams/daily-usage-data,null,2026-07-09 17:14:19.000
diff --git a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_members.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_members.csv
new file mode 100644
index 00000000000..6952772e184
--- /dev/null
+++ b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_members.csv
@@ -0,0 +1,2 @@
+id,params,data,url,input,created_at
+1,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""name"":""Example User"",""email"":""user@example.com"",""id"":""user_example123"",""role"":""member"",""isRemoved"":false}",https://api.cursor.com/teams/members,null,2026-07-09 17:14:19.000
diff --git a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_usage_events.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_usage_events.csv
new file mode 100644
index 00000000000..66f3948525a
--- /dev/null
+++ b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_usage_events.csv
@@ -0,0 +1,2 @@
+id,params,data,url,input,created_at
+1,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""timestamp"":""1783617259310"",""model"":""composer-2.5-fast"",""kind"":""Usage-based"",""maxMode"":false,""requestsCosts"":2,""isTokenBasedCall"":false,""tokenUsage"":{""inputTokens"":5932,""outputTokens"":4761,""cacheWriteTokens"":0,""cacheReadTokens"":495701,""totalCents"":33.70615},""userEmail"":""user@example.com"",""isChargeable"":true,""serviceAccountId"":""null"",""isHeadless"":false,""chargedCents"":8,""conversationId"":""846952e0-0221-473b-bb1d-e5d016f071b1""}",https://api.cursor.com/teams/filtered-usage-events,null,2026-07-09 17:14:19.000
diff --git a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_user_spend.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_user_spend.csv
new file mode 100644
index 00000000000..d4c4131147f
--- /dev/null
+++ b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_user_spend.csv
@@ -0,0 +1,2 @@
+id,params,data,url,input,created_at
+1,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""subscriptionCycleStart"":1781472188000,""collectedAt"":""2026-07-10T17:31:24.141244Z"",""member"":{""userId"":""user_example123"",""spendCents"":3224,""includedSpendCents"":1984,""fastPremiumRequests"":496,""name"":""Example User"",""email"":""user@example.com"",""profilePictureUrl"":null,""role"":""member"",""monthlyLimitDollars"":null,""hardLimitOverrideDollars"":0}}",https://api.cursor.com/teams/spend,null,2026-07-10 17:31:24.000
diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_daily_usage.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_daily_usage.csv
new file mode 100644
index 00000000000..c82f99aef6c
--- /dev/null
+++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_daily_usage.csv
@@ -0,0 +1,2 @@
+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,accepted_lines_added,accepted_lines_deleted,total_lines_added,total_lines_deleted,total_applies,total_accepts,total_rejects,lines_added,lines_deleted
+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,150,35,10,8,2,120,30
diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_members.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_members.csv
new file mode 100644
index 00000000000..8833567a1f4
--- /dev/null
+++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_members.csv
@@ -0,0 +1,2 @@
+connection_id,scope_id,user_id,email,name,role,is_removed
+1,team,user_example123,user@example.com,Example User,member,0
diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_usage_events.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_usage_events.csv
new file mode 100644
index 00000000000..84b99c6284c
--- /dev/null
+++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_usage_events.csv
@@ -0,0 +1,2 @@
+connection_id,scope_id,event_id,event_time,user_email,model,kind,conversation_id,charged_cents,requests_costs,is_token_based_call,is_chargeable,max_mode,is_headless,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,total_cents,cursor_token_fee,hosting_type,service_account_id
+1,team,2fe8f95cb983e68c7e944264bf1cc976c4940e1d3045f8fd5715fb66466d6eb7,2026-07-09T17:14:19.310+00:00,user@example.com,composer-2.5-fast,Usage-based,846952e0-0221-473b-bb1d-e5d016f071b1,8,2,0,1,0,0,5932,4761,495701,0,33.70615,0,,
diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_user_spend.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_user_spend.csv
new file mode 100644
index 00000000000..f1a1bcdbca5
--- /dev/null
+++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_user_spend.csv
@@ -0,0 +1,2 @@
+connection_id,scope_id,user_id,billing_cycle_start,collected_at,email,name,role,spend_cents,included_spend_cents,fast_premium_requests,monthly_limit_dollars,hard_limit_override_dollars
+1,team,user_example123,2026-06-14T21:23:08.000+00:00,2026-07-10T17:31:24.141+00:00,user@example.com,Example User,member,3224,1984,496,0,0
diff --git a/backend/plugins/cursor/impl/connection_helper.go b/backend/plugins/cursor/impl/connection_helper.go
new file mode 100644
index 00000000000..43d491afc3d
--- /dev/null
+++ b/backend/plugins/cursor/impl/connection_helper.go
@@ -0,0 +1,25 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package impl
+
+import "github.com/apache/incubator-devlake/plugins/cursor/models"
+
+// NormalizeConnection ensures required defaults are set before use.
+func NormalizeConnection(connection *models.CursorConnection) {
+ connection.Normalize()
+}
diff --git a/backend/plugins/cursor/impl/impl.go b/backend/plugins/cursor/impl/impl.go
new file mode 100644
index 00000000000..0d3b7193071
--- /dev/null
+++ b/backend/plugins/cursor/impl/impl.go
@@ -0,0 +1,168 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package impl
+
+import (
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/errors"
+ coreModels "github.com/apache/incubator-devlake/core/models"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+ "github.com/apache/incubator-devlake/plugins/cursor/models/migrationscripts"
+ "github.com/apache/incubator-devlake/plugins/cursor/tasks"
+)
+
+var _ interface {
+ plugin.PluginMeta
+ plugin.PluginInit
+ plugin.PluginTask
+ plugin.PluginApi
+ plugin.PluginModel
+ plugin.PluginSource
+ plugin.DataSourcePluginBlueprintV200
+ plugin.PluginMigration
+ plugin.CloseablePluginTask
+} = (*Cursor)(nil)
+
+// Cursor is the plugin entrypoint implementing DevLake interfaces.
+type Cursor struct{}
+
+func (p Cursor) Init(basicRes context.BasicRes) errors.Error {
+ api.Init(basicRes, p)
+ return nil
+}
+
+func (p Cursor) Description() string {
+ return "Collect Cursor team usage events and billing metrics from the Admin API"
+}
+
+func (p Cursor) Name() string {
+ return "cursor"
+}
+
+func (p Cursor) Connection() dal.Tabler {
+ return &models.CursorConnection{}
+}
+
+func (p Cursor) Scope() plugin.ToolLayerScope {
+ return &models.CursorScope{}
+}
+
+func (p Cursor) ScopeConfig() dal.Tabler {
+ return &models.CursorScopeConfig{}
+}
+
+func (p Cursor) GetTablesInfo() []dal.Tabler {
+ return models.GetTablesInfo()
+}
+
+func (p Cursor) SubTaskMetas() []plugin.SubTaskMeta {
+ return tasks.GetSubTaskMetas()
+}
+
+func (p Cursor) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) {
+ var op tasks.CursorOptions
+ if err := helper.Decode(options, &op, nil); err != nil {
+ return nil, err
+ }
+
+ connectionHelper := helper.NewConnectionHelper(taskCtx, nil, p.Name())
+ connection := &models.CursorConnection{}
+ if err := connectionHelper.FirstById(connection, op.ConnectionId); err != nil {
+ return nil, err
+ }
+
+ NormalizeConnection(connection)
+
+ return &tasks.CursorTaskData{
+ Options: &op,
+ Connection: connection,
+ }, nil
+}
+
+func (p Cursor) ApiResources() map[string]map[string]plugin.ApiResourceHandler {
+ return map[string]map[string]plugin.ApiResourceHandler{
+ "test": {
+ "POST": api.TestConnection,
+ },
+ "connections": {
+ "POST": api.PostConnections,
+ "GET": api.ListConnections,
+ },
+ "connections/:connectionId": {
+ "GET": api.GetConnection,
+ "PATCH": api.PatchConnection,
+ "DELETE": api.DeleteConnection,
+ },
+ "connections/:connectionId/test": {
+ "POST": api.TestExistingConnection,
+ },
+ "connections/:connectionId/scopes": {
+ "GET": api.GetScopeList,
+ "PUT": api.PutScopes,
+ },
+ "connections/:connectionId/scopes/:scopeId": {
+ "GET": api.GetScope,
+ "PATCH": api.PatchScope,
+ "DELETE": api.DeleteScope,
+ },
+ "connections/:connectionId/scopes/:scopeId/latest-sync-state": {
+ "GET": api.GetScopeLatestSyncState,
+ },
+ "connections/:connectionId/remote-scopes": {
+ "GET": api.RemoteScopes,
+ },
+ "connections/:connectionId/search-remote-scopes": {
+ "GET": api.SearchRemoteScopes,
+ },
+ "connections/:connectionId/scope-configs": {
+ "POST": api.PostScopeConfig,
+ "GET": api.GetScopeConfigList,
+ },
+ "connections/:connectionId/scope-configs/:scopeConfigId": {
+ "GET": api.GetScopeConfig,
+ "PATCH": api.PatchScopeConfig,
+ "DELETE": api.DeleteScopeConfig,
+ },
+ "scope-config/:scopeConfigId/projects": {
+ "GET": api.GetProjectsByScopeConfig,
+ },
+ }
+}
+
+func (p Cursor) MakeDataSourcePipelinePlanV200(
+ connectionId uint64,
+ scopes []*coreModels.BlueprintScope,
+) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
+ return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, scopes)
+}
+
+func (p Cursor) RootPkgPath() string {
+ return "github.com/apache/incubator-devlake/plugins/cursor"
+}
+
+func (p Cursor) MigrationScripts() []plugin.MigrationScript {
+ return migrationscripts.All()
+}
+
+func (p Cursor) Close(taskCtx plugin.TaskContext) errors.Error {
+ return nil
+}
diff --git a/backend/plugins/cursor/models/connection.go b/backend/plugins/cursor/models/connection.go
new file mode 100644
index 00000000000..8bb30c53082
--- /dev/null
+++ b/backend/plugins/cursor/models/connection.go
@@ -0,0 +1,105 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/utils"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+const (
+ // DefaultEndpoint is the Cursor Admin API base URL.
+ DefaultEndpoint = "https://api.cursor.com"
+ // DefaultRateLimitPerHour matches the documented 20 requests/minute Admin API limit.
+ DefaultRateLimitPerHour = 1200
+)
+
+// CursorConn stores Cursor Team Admin API connection settings.
+type CursorConn struct {
+ helper.RestConnection `mapstructure:",squash"`
+
+ Token string `mapstructure:"token" json:"token"`
+}
+
+// SetupAuthentication uses HTTP Basic auth with the API key as username and an empty password.
+func (conn *CursorConn) SetupAuthentication(request *http.Request) errors.Error {
+ if conn == nil {
+ return errors.BadInput.New("connection is required")
+ }
+ token := strings.TrimSpace(conn.Token)
+ if token == "" {
+ return errors.BadInput.New("token is required")
+ }
+ request.SetBasicAuth(token, "")
+ return nil
+}
+
+func (conn *CursorConn) Sanitize() CursorConn {
+ if conn == nil {
+ return CursorConn{}
+ }
+ clone := *conn
+ clone.Token = utils.SanitizeString(clone.Token)
+ return clone
+}
+
+// CursorConnection persists connection details with metadata required by DevLake.
+type CursorConnection struct {
+ helper.BaseConnection `mapstructure:",squash"`
+ CursorConn `mapstructure:",squash"`
+}
+
+func (CursorConnection) TableName() string {
+ return "_tool_cursor_connections"
+}
+
+func (connection CursorConnection) Sanitize() CursorConnection {
+ connection.CursorConn = connection.CursorConn.Sanitize()
+ return connection
+}
+
+func (connection *CursorConnection) Normalize() {
+ if connection == nil {
+ return
+ }
+ if connection.Endpoint == "" {
+ connection.Endpoint = DefaultEndpoint
+ }
+ if connection.RateLimitPerHour <= 0 {
+ connection.RateLimitPerHour = DefaultRateLimitPerHour
+ }
+}
+
+func (connection *CursorConnection) MergeFromRequest(target *CursorConnection, body map[string]interface{}) error {
+ if target == nil {
+ return nil
+ }
+ originalToken := target.Token
+ if err := helper.DecodeMapStruct(body, target, true); err != nil {
+ return err
+ }
+ sanitizedOriginal := utils.SanitizeString(originalToken)
+ if target.Token == "" || target.Token == sanitizedOriginal {
+ target.Token = originalToken
+ }
+ return nil
+}
diff --git a/backend/plugins/cursor/models/daily_usage.go b/backend/plugins/cursor/models/daily_usage.go
new file mode 100644
index 00000000000..d193e374896
--- /dev/null
+++ b/backend/plugins/cursor/models/daily_usage.go
@@ -0,0 +1,63 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// CursorDailyUsage stores per-user per-day adoption metrics from POST /teams/daily-usage-data.
+// Line fields map from acceptedLinesAdded/totalLinesAdded (etc.); LinesAdded/LinesDeleted
+// duplicate accepted_lines_added/accepted_lines_deleted for backward compatibility.
+type CursorDailyUsage struct {
+ ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"`
+ UserId string `gorm:"primaryKey;type:varchar(255)" json:"userId"`
+ UsageDate time.Time `gorm:"primaryKey" json:"usageDate"`
+
+ Email string `json:"email" gorm:"type:varchar(255);index"`
+ IsActive bool `json:"isActive"`
+ Completions int `json:"completions"`
+ PremiumRequests int `json:"premiumRequests"`
+ AgentRequests int `json:"agentRequests"`
+ ChatRequests int `json:"chatRequests"`
+ ComposerRequests int `json:"composerRequests"`
+ TabsAccepted int `json:"tabsAccepted"`
+ TabsShown int `json:"tabsShown"`
+ UsageBasedReqs int `json:"usageBasedReqs"`
+ SubscriptionIncludedReqs int `json:"subscriptionIncludedReqs"`
+ MostUsedModel string `json:"mostUsedModel" gorm:"type:varchar(255)"`
+ ClientVersion string `json:"clientVersion" gorm:"type:varchar(100)"`
+ AcceptedLinesAdded int `json:"acceptedLinesAdded"`
+ AcceptedLinesDeleted int `json:"acceptedLinesDeleted"`
+ TotalLinesAdded int `json:"totalLinesAdded"`
+ TotalLinesDeleted int `json:"totalLinesDeleted"`
+ TotalApplies int `json:"totalApplies"`
+ TotalAccepts int `json:"totalAccepts"`
+ TotalRejects int `json:"totalRejects"`
+ LinesAdded int `json:"linesAdded"`
+ LinesDeleted int `json:"linesDeleted"`
+
+ common.NoPKModel
+}
+
+func (CursorDailyUsage) TableName() string {
+ return "_tool_cursor_daily_usage"
+}
diff --git a/backend/plugins/cursor/models/member.go b/backend/plugins/cursor/models/member.go
new file mode 100644
index 00000000000..63a1f24e6c8
--- /dev/null
+++ b/backend/plugins/cursor/models/member.go
@@ -0,0 +1,40 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// CursorMember stores team member roster from /teams/members.
+type CursorMember struct {
+ ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"`
+ UserId string `gorm:"primaryKey;type:varchar(255)" json:"userId"`
+
+ Email string `json:"email" gorm:"type:varchar(255);index"`
+ Name string `json:"name" gorm:"type:varchar(255)"`
+ Role string `json:"role" gorm:"type:varchar(50)"`
+ IsRemoved bool `json:"isRemoved"`
+
+ common.NoPKModel
+}
+
+func (CursorMember) TableName() string {
+ return "_tool_cursor_members"
+}
diff --git a/backend/plugins/cursor/models/migrationscripts/20260709_initialize.go b/backend/plugins/cursor/models/migrationscripts/20260709_initialize.go
new file mode 100644
index 00000000000..0bd85596326
--- /dev/null
+++ b/backend/plugins/cursor/models/migrationscripts/20260709_initialize.go
@@ -0,0 +1,174 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+type addCursorInitialTables struct{}
+
+func (script *addCursorInitialTables) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(
+ basicRes,
+ &cursorConnection20260709{},
+ &cursorScope20260709{},
+ &cursorScopeConfig20260709{},
+ &cursorUsageEvent20260709{},
+ &cursorUserSpend20260709{},
+ &cursorMember20260709{},
+ &cursorRawUsageEvents20260709{},
+ &cursorRawUserSpend20260709{},
+ &cursorRawMembers20260709{},
+ )
+}
+
+type cursorConnection20260709 struct {
+ archived.Model
+ Name string `gorm:"type:varchar(100);uniqueIndex" json:"name"`
+ Endpoint string `gorm:"type:varchar(255)" json:"endpoint"`
+ Proxy string `gorm:"type:varchar(255)" json:"proxy"`
+ RateLimitPerHour int `json:"rateLimitPerHour"`
+ Token string `json:"token"`
+}
+
+func (cursorConnection20260709) TableName() string { return "_tool_cursor_connections" }
+
+type cursorScope20260709 struct {
+ archived.NoPKModel
+ ConnectionId uint64 `json:"connectionId" gorm:"primaryKey"`
+ ScopeConfigId uint64 `json:"scopeConfigId,omitempty"`
+ Id string `json:"id" gorm:"primaryKey;type:varchar(255)"`
+ TeamId string `json:"teamId" gorm:"type:varchar(255)"`
+ Name string `json:"name" gorm:"type:varchar(255)"`
+ FullName string `json:"fullName" gorm:"type:varchar(255)"`
+}
+
+func (cursorScope20260709) TableName() string { return "_tool_cursor_scopes" }
+
+type cursorScopeConfig20260709 struct {
+ archived.Model
+ Entities []string `gorm:"type:json;serializer:json" json:"entities" mapstructure:"entities"`
+ ConnectionId uint64 `json:"connectionId" gorm:"index" validate:"required" mapstructure:"connectionId,omitempty"`
+ Name string `mapstructure:"name" json:"name" gorm:"type:varchar(255);uniqueIndex" validate:"required"`
+}
+
+func (cursorScopeConfig20260709) TableName() string { return "_tool_cursor_scope_configs" }
+
+type cursorUsageEvent20260709 struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ EventId string `gorm:"primaryKey;type:varchar(64)"`
+ EventTime time.Time `gorm:"index"`
+ UserEmail string `gorm:"type:varchar(255);index"`
+ Model string `gorm:"type:varchar(255);index"`
+ Kind string `gorm:"type:varchar(100)"`
+ ConversationId string `gorm:"type:varchar(64);index"`
+ ChargedCents float64
+ RequestsCosts int
+ IsTokenBasedCall bool
+ IsChargeable bool
+ MaxMode bool
+ IsHeadless bool
+ InputTokens int
+ OutputTokens int
+ CacheReadTokens int
+ CacheWriteTokens int
+ TotalCents float64
+ CursorTokenFee float64
+ HostingType string `gorm:"type:varchar(50)"`
+ ServiceAccountId string `gorm:"type:varchar(255)"`
+ archived.NoPKModel
+}
+
+func (cursorUsageEvent20260709) TableName() string { return "_tool_cursor_usage_events" }
+
+type cursorUserSpend20260709 struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ UserId string `gorm:"primaryKey;type:varchar(255)"`
+ BillingCycleStart time.Time `gorm:"primaryKey"`
+ CollectedAt time.Time `gorm:"index"`
+ Email string `gorm:"type:varchar(255);index"`
+ Name string `gorm:"type:varchar(255)"`
+ Role string `gorm:"type:varchar(50)"`
+ SpendCents float64
+ IncludedSpendCents float64
+ FastPremiumRequests int
+ MonthlyLimitDollars float64
+ HardLimitOverrideDollars float64
+ archived.NoPKModel
+}
+
+func (cursorUserSpend20260709) TableName() string { return "_tool_cursor_user_spend" }
+
+type cursorMember20260709 struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ UserId string `gorm:"primaryKey;type:varchar(255)"`
+ Email string `gorm:"type:varchar(255);index"`
+ Name string `gorm:"type:varchar(255)"`
+ Role string `gorm:"type:varchar(50)"`
+ IsRemoved bool
+ archived.NoPKModel
+}
+
+func (cursorMember20260709) TableName() string { return "_tool_cursor_members" }
+
+type cursorRawUsageEvents20260709 struct {
+ ID uint64 `gorm:"primaryKey"`
+ Params string `gorm:"type:varchar(255);index"`
+ Data []byte
+ Url string
+ Input json.RawMessage `gorm:"type:json"`
+ CreatedAt time.Time `gorm:"index"`
+}
+
+func (cursorRawUsageEvents20260709) TableName() string { return "_raw_cursor_usage_events" }
+
+type cursorRawUserSpend20260709 struct {
+ ID uint64 `gorm:"primaryKey"`
+ Params string `gorm:"type:varchar(255);index"`
+ Data []byte
+ Url string
+ Input json.RawMessage `gorm:"type:json"`
+ CreatedAt time.Time `gorm:"index"`
+}
+
+func (cursorRawUserSpend20260709) TableName() string { return "_raw_cursor_user_spend" }
+
+type cursorRawMembers20260709 struct {
+ ID uint64 `gorm:"primaryKey"`
+ Params string `gorm:"type:varchar(255);index"`
+ Data []byte
+ Url string
+ Input json.RawMessage `gorm:"type:json"`
+ CreatedAt time.Time `gorm:"index"`
+}
+
+func (cursorRawMembers20260709) TableName() string { return "_raw_cursor_members" }
+
+func (*addCursorInitialTables) Version() uint64 { return 20260709000000 }
+
+func (*addCursorInitialTables) Name() string { return "cursor init tables" }
diff --git a/backend/plugins/cursor/models/migrationscripts/20260710_requests_costs_float.go b/backend/plugins/cursor/models/migrationscripts/20260710_requests_costs_float.go
new file mode 100644
index 00000000000..2b4acf9cb4e
--- /dev/null
+++ b/backend/plugins/cursor/models/migrationscripts/20260710_requests_costs_float.go
@@ -0,0 +1,39 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+)
+
+type changeCursorRequestsCostsToFloat struct{}
+
+func (script *changeCursorRequestsCostsToFloat) Up(basicRes context.BasicRes) errors.Error {
+ db := basicRes.GetDal()
+ if !db.HasTable("_tool_cursor_usage_events") {
+ return nil
+ }
+ return db.Exec("ALTER TABLE _tool_cursor_usage_events MODIFY COLUMN requests_costs DOUBLE")
+}
+
+func (*changeCursorRequestsCostsToFloat) Version() uint64 { return 20260710120000 }
+
+func (*changeCursorRequestsCostsToFloat) Name() string {
+ return "cursor change requests_costs column to double"
+}
diff --git a/backend/plugins/cursor/models/migrationscripts/20260714_add_daily_usage.go b/backend/plugins/cursor/models/migrationscripts/20260714_add_daily_usage.go
new file mode 100644
index 00000000000..c41eccec65a
--- /dev/null
+++ b/backend/plugins/cursor/models/migrationscripts/20260714_add_daily_usage.go
@@ -0,0 +1,78 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+type addCursorDailyUsage struct{}
+
+type cursorDailyUsage20260714 struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ UserId string `gorm:"primaryKey;type:varchar(255)"`
+ UsageDate time.Time `gorm:"primaryKey"`
+ Email string `gorm:"type:varchar(255);index"`
+ IsActive bool
+ Completions int
+ PremiumRequests int
+ AgentRequests int
+ ChatRequests int
+ ComposerRequests int
+ TabsAccepted int
+ TabsShown int
+ UsageBasedReqs int
+ SubscriptionIncludedReqs int
+ MostUsedModel string `gorm:"type:varchar(255)"`
+ ClientVersion string `gorm:"type:varchar(100)"`
+ LinesAdded int
+ LinesDeleted int
+ archived.NoPKModel
+}
+
+func (cursorDailyUsage20260714) TableName() string { return "_tool_cursor_daily_usage" }
+
+type cursorRawDailyUsage20260714 struct {
+ ID uint64 `gorm:"primaryKey"`
+ Params string `gorm:"type:varchar(255);index"`
+ Data []byte
+ Url string
+ Input json.RawMessage `gorm:"type:json"`
+ CreatedAt time.Time `gorm:"index"`
+}
+
+func (cursorRawDailyUsage20260714) TableName() string { return "_raw_cursor_daily_usage" }
+
+func (script *addCursorDailyUsage) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(
+ basicRes,
+ &cursorDailyUsage20260714{},
+ &cursorRawDailyUsage20260714{},
+ )
+}
+
+func (*addCursorDailyUsage) Version() uint64 { return 20260714000000 }
+
+func (*addCursorDailyUsage) Name() string { return "cursor add daily usage tables" }
diff --git a/backend/plugins/cursor/models/migrationscripts/20260716_add_daily_usage_line_fields.go b/backend/plugins/cursor/models/migrationscripts/20260716_add_daily_usage_line_fields.go
new file mode 100644
index 00000000000..4abedb7e92c
--- /dev/null
+++ b/backend/plugins/cursor/models/migrationscripts/20260716_add_daily_usage_line_fields.go
@@ -0,0 +1,71 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+type addCursorDailyUsageLineFields struct{}
+
+type cursorDailyUsage20260716 struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ UserId string `gorm:"primaryKey;type:varchar(255)"`
+ UsageDate time.Time `gorm:"primaryKey"`
+ Email string `gorm:"type:varchar(255);index"`
+ IsActive bool
+ Completions int
+ PremiumRequests int
+ AgentRequests int
+ ChatRequests int
+ ComposerRequests int
+ TabsAccepted int
+ TabsShown int
+ UsageBasedReqs int
+ SubscriptionIncludedReqs int
+ MostUsedModel string `gorm:"type:varchar(255)"`
+ ClientVersion string `gorm:"type:varchar(100)"`
+ AcceptedLinesAdded int
+ AcceptedLinesDeleted int
+ TotalLinesAdded int
+ TotalLinesDeleted int
+ TotalApplies int
+ TotalAccepts int
+ TotalRejects int
+ LinesAdded int
+ LinesDeleted int
+ archived.NoPKModel
+}
+
+func (cursorDailyUsage20260716) TableName() string { return "_tool_cursor_daily_usage" }
+
+func (script *addCursorDailyUsageLineFields) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(basicRes, &cursorDailyUsage20260716{})
+}
+
+func (*addCursorDailyUsageLineFields) Version() uint64 { return 20260716120000 }
+
+func (*addCursorDailyUsageLineFields) Name() string {
+ return "cursor add daily usage line and apply fields"
+}
diff --git a/backend/plugins/cursor/models/migrationscripts/register.go b/backend/plugins/cursor/models/migrationscripts/register.go
new file mode 100644
index 00000000000..24e5eedae47
--- /dev/null
+++ b/backend/plugins/cursor/models/migrationscripts/register.go
@@ -0,0 +1,30 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import "github.com/apache/incubator-devlake/core/plugin"
+
+// All returns the ordered list of migration scripts for the Cursor plugin.
+func All() []plugin.MigrationScript {
+ return []plugin.MigrationScript{
+ new(addCursorInitialTables),
+ new(changeCursorRequestsCostsToFloat),
+ new(addCursorDailyUsage),
+ new(addCursorDailyUsageLineFields),
+ }
+}
diff --git a/backend/plugins/cursor/models/models.go b/backend/plugins/cursor/models/models.go
new file mode 100644
index 00000000000..6a2ec5d8831
--- /dev/null
+++ b/backend/plugins/cursor/models/models.go
@@ -0,0 +1,33 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import "github.com/apache/incubator-devlake/core/dal"
+
+// GetTablesInfo returns the list of tool-layer tables managed by the Cursor plugin.
+func GetTablesInfo() []dal.Tabler {
+ return []dal.Tabler{
+ &CursorConnection{},
+ &CursorScope{},
+ &CursorScopeConfig{},
+ &CursorUsageEvent{},
+ &CursorUserSpend{},
+ &CursorMember{},
+ &CursorDailyUsage{},
+ }
+}
diff --git a/backend/plugins/cursor/models/scope.go b/backend/plugins/cursor/models/scope.go
new file mode 100644
index 00000000000..00cd8e745b5
--- /dev/null
+++ b/backend/plugins/cursor/models/scope.go
@@ -0,0 +1,100 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "strings"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "gorm.io/gorm"
+)
+
+const (
+ // DefaultScopeID is the single team-level scope for Cursor Admin API data.
+ DefaultScopeID = "team"
+)
+
+// CursorScope represents a team-level collection scope.
+type CursorScope struct {
+ common.Scope `mapstructure:",squash"`
+ Id string `json:"id" mapstructure:"id" gorm:"primaryKey;type:varchar(255)"`
+ TeamId string `json:"teamId" mapstructure:"teamId" gorm:"type:varchar(255)"`
+ Name string `json:"name" mapstructure:"name" gorm:"type:varchar(255)"`
+ FullName string `json:"fullName" mapstructure:"fullName" gorm:"type:varchar(255)"`
+}
+
+func (CursorScope) TableName() string {
+ return "_tool_cursor_scopes"
+}
+
+func (s *CursorScope) BeforeSave(tx *gorm.DB) error {
+ if s == nil {
+ return nil
+ }
+
+ s.Id = strings.TrimSpace(s.Id)
+ s.TeamId = strings.TrimSpace(s.TeamId)
+ s.Name = strings.TrimSpace(s.Name)
+ s.FullName = strings.TrimSpace(s.FullName)
+
+ if s.Id == "" {
+ s.Id = DefaultScopeID
+ }
+ if s.Name == "" {
+ s.Name = s.ScopeName()
+ }
+ if s.FullName == "" {
+ s.FullName = s.ScopeFullName()
+ }
+
+ return nil
+}
+
+func (s CursorScope) ScopeId() string {
+ return s.Id
+}
+
+func (s CursorScope) ScopeName() string {
+ if s.Name != "" {
+ return s.Name
+ }
+ return DefaultScopeID
+}
+
+func (s CursorScope) ScopeFullName() string {
+ if s.FullName != "" {
+ return s.FullName
+ }
+ return s.ScopeName()
+}
+
+func (s CursorScope) ScopeParams() interface{} {
+ return &CursorScopeParams{
+ ConnectionId: s.ConnectionId,
+ ScopeId: s.Id,
+ }
+}
+
+// CursorScopeParams is returned for blueprint configuration.
+type CursorScopeParams struct {
+ ConnectionId uint64 `json:"connectionId"`
+ ScopeId string `json:"scopeId"`
+}
+
+var _ plugin.ToolLayerScope = (*CursorScope)(nil)
diff --git a/backend/plugins/cursor/models/scope_config.go b/backend/plugins/cursor/models/scope_config.go
new file mode 100644
index 00000000000..64dcf5e259b
--- /dev/null
+++ b/backend/plugins/cursor/models/scope_config.go
@@ -0,0 +1,38 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "github.com/apache/incubator-devlake/core/models/common"
+ "github.com/apache/incubator-devlake/core/plugin"
+)
+
+var _ plugin.ToolLayerScopeConfig = (*CursorScopeConfig)(nil)
+
+// CursorScopeConfig contains configuration for Cursor data scope.
+type CursorScopeConfig struct {
+ common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"`
+}
+
+func (CursorScopeConfig) TableName() string {
+ return "_tool_cursor_scope_configs"
+}
+
+func (sc CursorScopeConfig) GetConnectionId() uint64 {
+ return sc.ConnectionId
+}
diff --git a/backend/plugins/cursor/models/usage_event.go b/backend/plugins/cursor/models/usage_event.go
new file mode 100644
index 00000000000..5ae4f7e7f8a
--- /dev/null
+++ b/backend/plugins/cursor/models/usage_event.go
@@ -0,0 +1,57 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// CursorUsageEvent stores one usage event from /teams/filtered-usage-events.
+type CursorUsageEvent struct {
+ ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"`
+ EventId string `gorm:"primaryKey;type:varchar(64)" json:"eventId"`
+ EventTime time.Time `gorm:"index" json:"eventTime"`
+
+ UserEmail string `json:"userEmail" gorm:"type:varchar(255);index"`
+ Model string `json:"model" gorm:"type:varchar(255);index"`
+ Kind string `json:"kind" gorm:"type:varchar(100)"`
+ ConversationId string `json:"conversationId" gorm:"type:varchar(64);index"`
+ ChargedCents float64 `json:"chargedCents"`
+ RequestsCosts float64 `json:"requestsCosts"`
+ IsTokenBasedCall bool `json:"isTokenBasedCall"`
+ IsChargeable bool `json:"isChargeable"`
+ MaxMode bool `json:"maxMode"`
+ IsHeadless bool `json:"isHeadless"`
+ InputTokens int `json:"inputTokens"`
+ OutputTokens int `json:"outputTokens"`
+ CacheReadTokens int `json:"cacheReadTokens"`
+ CacheWriteTokens int `json:"cacheWriteTokens"`
+ TotalCents float64 `json:"totalCents"`
+ CursorTokenFee float64 `json:"cursorTokenFee"`
+ HostingType string `json:"hostingType" gorm:"type:varchar(50)"`
+ ServiceAccountId string `json:"serviceAccountId" gorm:"type:varchar(255)"`
+
+ common.NoPKModel
+}
+
+func (CursorUsageEvent) TableName() string {
+ return "_tool_cursor_usage_events"
+}
diff --git a/backend/plugins/cursor/models/user_spend.go b/backend/plugins/cursor/models/user_spend.go
new file mode 100644
index 00000000000..c936159482d
--- /dev/null
+++ b/backend/plugins/cursor/models/user_spend.go
@@ -0,0 +1,48 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// CursorUserSpend stores per-user billing cycle spend from /teams/spend.
+type CursorUserSpend struct {
+ ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"`
+ UserId string `gorm:"primaryKey;type:varchar(255)" json:"userId"`
+ BillingCycleStart time.Time `gorm:"primaryKey" json:"billingCycleStart"`
+ CollectedAt time.Time `gorm:"index" json:"collectedAt"`
+
+ Email string `json:"email" gorm:"type:varchar(255);index"`
+ Name string `json:"name" gorm:"type:varchar(255)"`
+ Role string `json:"role" gorm:"type:varchar(50)"`
+ SpendCents float64 `json:"spendCents"`
+ IncludedSpendCents float64 `json:"includedSpendCents"`
+ FastPremiumRequests int `json:"fastPremiumRequests"`
+ MonthlyLimitDollars float64 `json:"monthlyLimitDollars"`
+ HardLimitOverrideDollars float64 `json:"hardLimitOverrideDollars"`
+
+ common.NoPKModel
+}
+
+func (CursorUserSpend) TableName() string {
+ return "_tool_cursor_user_spend"
+}
diff --git a/backend/plugins/cursor/service/connection_test.go b/backend/plugins/cursor/service/connection_test.go
new file mode 100644
index 00000000000..c520351f1e5
--- /dev/null
+++ b/backend/plugins/cursor/service/connection_test.go
@@ -0,0 +1,59 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package service
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildPermissionFailureMessage(t *testing.T) {
+ message := buildPermissionFailureMessage([]string{
+ "Cannot read team billing spend (teams/spend): forbidden.",
+ "Cannot read filtered usage events (teams/filtered-usage-events): unauthorized.",
+ })
+ require.Contains(t, message, "missing required permissions")
+ require.Contains(t, message, "teams/spend")
+ require.Contains(t, message, "filtered-usage-events")
+}
+
+func TestBuildAdminApiError_Unauthorized(t *testing.T) {
+ res := &http.Response{
+ StatusCode: http.StatusUnauthorized,
+ Body: io.NopCloser(strings.NewReader(`{"message":"invalid key"}`)),
+ }
+ err := buildAdminApiError(spendEndpoint, "read team billing spend", res)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "Team Admin API key")
+ require.Contains(t, err.Error(), "teams/spend")
+}
+
+func TestBuildAdminApiError_Forbidden(t *testing.T) {
+ res := &http.Response{
+ StatusCode: http.StatusForbidden,
+ Body: io.NopCloser(strings.NewReader(`{"message":"forbidden"}`)),
+ }
+ err := buildAdminApiError(usageEventsEndpoint, "read filtered usage events", res)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "forbidden")
+ require.Contains(t, err.Error(), "filtered-usage-events")
+}
diff --git a/backend/plugins/cursor/service/connection_test_helper.go b/backend/plugins/cursor/service/connection_test_helper.go
new file mode 100644
index 00000000000..3ae190865b2
--- /dev/null
+++ b/backend/plugins/cursor/service/connection_test_helper.go
@@ -0,0 +1,244 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package service
+
+import (
+ stdctx "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ corectx "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+const (
+ membersEndpoint = "teams/members"
+ spendEndpoint = "teams/spend"
+ usageEventsEndpoint = "teams/filtered-usage-events"
+)
+
+// AdminApiPermissions reports which Cursor Admin API endpoints the key can access.
+type AdminApiPermissions struct {
+ Members bool `json:"members"`
+ Spend bool `json:"spend"`
+ UsageEvents bool `json:"usageEvents"`
+}
+
+// TestConnectionResult represents the payload returned by the connection test endpoints.
+type TestConnectionResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ MemberCount int `json:"memberCount,omitempty"`
+ Permissions AdminApiPermissions `json:"permissions,omitempty"`
+ HasEnterpriseAnalytics bool `json:"hasEnterpriseAnalytics,omitempty"`
+}
+
+// TestConnection exercises the Cursor Admin API to validate credentials and permissions.
+func TestConnection(ctx stdctx.Context, br corectx.BasicRes, connection *models.CursorConnection) (*TestConnectionResult, errors.Error) {
+ if connection == nil {
+ return nil, errors.BadInput.New("connection is required")
+ }
+
+ connection.Normalize()
+ if strings.TrimSpace(connection.Token) == "" {
+ return nil, errors.BadInput.New("token is required")
+ }
+
+ apiClient, err := helper.NewApiClientFromConnection(ctx, br, connection)
+ if err != nil {
+ return nil, err
+ }
+ apiClient.SetHeaders(map[string]string{
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ })
+
+ if userKeyErr := detectUserApiKey(apiClient); userKeyErr != nil {
+ return &TestConnectionResult{
+ Success: false,
+ Message: userKeyErr.Error(),
+ }, nil
+ }
+
+ permissions := AdminApiPermissions{}
+ var failures []string
+
+ memberCount, membersErr := probeMembers(apiClient)
+ permissions.Members = membersErr == nil
+ if membersErr != nil {
+ failures = append(failures, membersErr.Error())
+ }
+
+ if spendErr := probeSpend(apiClient); spendErr != nil {
+ permissions.Spend = false
+ failures = append(failures, spendErr.Error())
+ } else {
+ permissions.Spend = true
+ }
+
+ if usageErr := probeUsageEvents(apiClient); usageErr != nil {
+ permissions.UsageEvents = false
+ failures = append(failures, usageErr.Error())
+ } else {
+ permissions.UsageEvents = true
+ }
+
+ if len(failures) > 0 {
+ return &TestConnectionResult{
+ Success: false,
+ Message: buildPermissionFailureMessage(failures),
+ MemberCount: memberCount,
+ Permissions: permissions,
+ }, nil
+ }
+
+ hasEnterpriseAnalytics := probeEnterpriseAnalytics(apiClient)
+
+ return &TestConnectionResult{
+ Success: true,
+ Message: "Team Admin API key validated. Members, spend, and usage events are accessible.",
+ MemberCount: memberCount,
+ Permissions: permissions,
+ HasEnterpriseAnalytics: hasEnterpriseAnalytics,
+ }, nil
+}
+
+func probeMembers(apiClient *helper.ApiClient) (int, errors.Error) {
+ res, err := apiClient.Get(membersEndpoint, nil, nil)
+ if err != nil {
+ return 0, errors.Default.Wrap(err, "failed to reach Cursor Admin API")
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return 0, buildAdminApiError(membersEndpoint, "list team members", res)
+ }
+
+ body, readErr := io.ReadAll(res.Body)
+ if readErr != nil {
+ return 0, errors.Default.Wrap(readErr, "failed to read members response")
+ }
+
+ var response struct {
+ TeamMembers []json.RawMessage `json:"teamMembers"`
+ }
+ if jsonErr := json.Unmarshal(body, &response); jsonErr != nil {
+ return 0, errors.Default.Wrap(errors.Convert(jsonErr), "failed to parse members response")
+ }
+
+ return len(response.TeamMembers), nil
+}
+
+func probeSpend(apiClient *helper.ApiClient) errors.Error {
+ res, err := apiClient.Post(spendEndpoint, nil, map[string]interface{}{
+ "page": 1,
+ "pageSize": 1,
+ }, nil)
+ if err != nil {
+ return errors.Default.Wrap(err, "failed to reach Cursor spend API")
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return buildAdminApiError(spendEndpoint, "read team billing spend", res)
+ }
+ return nil
+}
+
+func probeUsageEvents(apiClient *helper.ApiClient) errors.Error {
+ endMs := time.Now().UTC().UnixMilli()
+ startMs := endMs - int64(7*24*time.Hour/time.Millisecond)
+
+ res, err := apiClient.Post(usageEventsEndpoint, nil, map[string]interface{}{
+ "startDate": startMs,
+ "endDate": endMs,
+ "page": 1,
+ "pageSize": 1,
+ }, nil)
+ if err != nil {
+ return errors.Default.Wrap(err, "failed to reach Cursor usage events API")
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return buildAdminApiError(usageEventsEndpoint, "read filtered usage events", res)
+ }
+ return nil
+}
+
+func buildAdminApiError(endpoint, action string, res *http.Response) errors.Error {
+ body, _ := io.ReadAll(res.Body)
+ detail := strings.TrimSpace(string(body))
+ if detail != "" && len(detail) > 200 {
+ detail = detail[:200] + "..."
+ }
+
+ switch res.StatusCode {
+ case http.StatusUnauthorized:
+ return errors.BadInput.New(fmt.Sprintf(
+ "Cannot %s (%s): unauthorized. Use a Team Admin API key from Dashboard → API Keys (admin:* scope), not a User API key from Settings → Integrations.",
+ action, endpoint,
+ ))
+ case http.StatusForbidden:
+ msg := fmt.Sprintf("Cannot %s (%s): forbidden. The key may lack admin permissions for this team.", action, endpoint)
+ if detail != "" {
+ msg = fmt.Sprintf("%s Details: %s", msg, detail)
+ }
+ return errors.BadInput.New(msg)
+ default:
+ msg := fmt.Sprintf("Cannot %s (%s): unexpected status %d", action, endpoint, res.StatusCode)
+ if detail != "" {
+ msg = fmt.Sprintf("%s. Details: %s", msg, detail)
+ }
+ return errors.BadInput.New(msg)
+ }
+}
+
+func buildPermissionFailureMessage(failures []string) string {
+ if len(failures) == 1 {
+ return failures[0]
+ }
+ return "Team Admin API key is missing required permissions:\n- " + strings.Join(failures, "\n- ")
+}
+
+func detectUserApiKey(apiClient *helper.ApiClient) errors.Error {
+ res, err := apiClient.Get("v1/me", nil, nil)
+ if err != nil || res == nil {
+ return nil
+ }
+ defer res.Body.Close()
+ if res.StatusCode == http.StatusOK {
+ return errors.BadInput.New("this is a User API key. Create a Team Admin key in Dashboard → API Keys (admin:* scope)")
+ }
+ return nil
+}
+
+func probeEnterpriseAnalytics(apiClient *helper.ApiClient) bool {
+ res, err := apiClient.Get("analytics/team/dau?startDate=7d&endDate=today", nil, nil)
+ if err != nil || res == nil {
+ return false
+ }
+ defer res.Body.Close()
+ return res.StatusCode == http.StatusOK
+}
diff --git a/backend/plugins/cursor/tasks/api_client.go b/backend/plugins/cursor/tasks/api_client.go
new file mode 100644
index 00000000000..975e805dc1c
--- /dev/null
+++ b/backend/plugins/cursor/tasks/api_client.go
@@ -0,0 +1,53 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+// CreateApiClient creates an async API client for Cursor Admin API collectors.
+func CreateApiClient(taskCtx plugin.TaskContext, connection *models.CursorConnection) (*helper.ApiAsyncClient, errors.Error) {
+ apiClient, err := helper.NewApiClientFromConnection(taskCtx.GetContext(), taskCtx, connection)
+ if err != nil {
+ return nil, err
+ }
+
+ apiClient.SetHeaders(map[string]string{
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ })
+
+ rateLimiter := &helper.ApiRateLimitCalculator{UserRateLimitPerHour: connection.RateLimitPerHour}
+ asyncClient, err := helper.CreateAsyncApiClient(taskCtx, apiClient, rateLimiter)
+ if err != nil {
+ return nil, err
+ }
+
+ apiClient.SetAfterFunction(func(res *http.Response) errors.Error {
+ return handleCursorRetryAfter(res, taskCtx.GetLogger(), time.Now, time.Sleep)
+ })
+
+ return asyncClient, nil
+}
diff --git a/backend/plugins/cursor/tasks/collector_utils.go b/backend/plugins/cursor/tasks/collector_utils.go
new file mode 100644
index 00000000000..612ba5ec774
--- /dev/null
+++ b/backend/plugins/cursor/tasks/collector_utils.go
@@ -0,0 +1,257 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+const (
+ rawUsageEventsTable = "cursor_usage_events"
+ rawUserSpendTable = "cursor_user_spend"
+ rawMembersTable = "cursor_members"
+ rawDailyUsageTable = "cursor_daily_usage"
+
+ cursorApiPageSize = 100
+ cursorInitialBackfillDays = 90
+ // cursorDailyUsageMaxDays is the maximum span allowed by POST /teams/daily-usage-data.
+ cursorDailyUsageMaxDays = 30
+)
+
+type cursorRawParams struct {
+ ConnectionId uint64
+ ScopeId string
+ Endpoint string
+}
+
+func (p cursorRawParams) GetParams() any {
+ return p
+}
+
+func rawParamsFromTaskData(data *CursorTaskData) cursorRawParams {
+ endpoint := models.DefaultEndpoint
+ if data.Connection != nil {
+ data.Connection.Normalize()
+ endpoint = data.Connection.Endpoint
+ }
+ return cursorRawParams{
+ ConnectionId: data.Options.ConnectionId,
+ ScopeId: data.Options.ScopeId,
+ Endpoint: endpoint,
+ }
+}
+
+type cursorTimeRangeInput struct {
+ StartDateMs int64 `json:"startDateMs"`
+ EndDateMs int64 `json:"endDateMs"`
+}
+
+type cursorPageState struct {
+ Page int `json:"page"`
+}
+
+type cursorPagination struct {
+ NumPages int `json:"numPages"`
+ CurrentPage int `json:"currentPage"`
+ PageSize int `json:"pageSize"`
+ HasNextPage bool `json:"hasNextPage"`
+ HasPreviousPage bool `json:"hasPreviousPage"`
+ Page int `json:"page"`
+ TotalPages int `json:"totalPages"`
+}
+
+func computeUsageTimeRangeMs(since *time.Time, now time.Time) (int64, int64) {
+ end := now.UTC()
+ start := end.AddDate(0, 0, -cursorInitialBackfillDays)
+ if since != nil && !since.IsZero() && since.After(start) {
+ start = since.UTC()
+ }
+ return start.UnixMilli(), end.UnixMilli()
+}
+
+// splitDailyUsageTimeRangeMs splits [startMs, endMs] into chunks of at most maxDays.
+// Used by daily-usage-data (30-day API limit) and filtered-usage-events collectors.
+func splitDailyUsageTimeRangeMs(startMs, endMs int64, maxDays int) []cursorTimeRangeInput {
+ if startMs >= endMs || maxDays <= 0 {
+ return nil
+ }
+ maxDuration := time.Duration(maxDays) * 24 * time.Hour
+ chunkStart := time.UnixMilli(startMs).UTC()
+ end := time.UnixMilli(endMs).UTC()
+
+ var chunks []cursorTimeRangeInput
+ for chunkStart.Before(end) {
+ chunkEnd := chunkStart.Add(maxDuration)
+ if chunkEnd.After(end) {
+ chunkEnd = end
+ }
+ chunks = append(chunks, cursorTimeRangeInput{
+ StartDateMs: chunkStart.UnixMilli(),
+ EndDateMs: chunkEnd.UnixMilli(),
+ })
+ if !chunkEnd.Before(end) {
+ break
+ }
+ chunkStart = chunkEnd.Add(time.Millisecond)
+ }
+ return chunks
+}
+
+func parseUsageEventsResponse(res *http.Response) ([]json.RawMessage, errors.Error) {
+ body, err := readResponseBody(res)
+ if err != nil {
+ return nil, err
+ }
+ var response struct {
+ UsageEvents []json.RawMessage `json:"usageEvents"`
+ }
+ if jsonErr := json.Unmarshal(body, &response); jsonErr != nil {
+ return nil, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode usage events response")
+ }
+ return response.UsageEvents, nil
+}
+
+func parseSpendMembersResponse(res *http.Response) ([]json.RawMessage, errors.Error) {
+ body, err := readResponseBody(res)
+ if err != nil {
+ return nil, err
+ }
+ var response struct {
+ TeamMemberSpend []json.RawMessage `json:"teamMemberSpend"`
+ }
+ if jsonErr := json.Unmarshal(body, &response); jsonErr != nil {
+ return nil, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode spend response")
+ }
+ return response.TeamMemberSpend, nil
+}
+
+func parseDailyUsageResponse(res *http.Response) ([]json.RawMessage, errors.Error) {
+ body, err := readResponseBody(res)
+ if err != nil {
+ return nil, err
+ }
+ var response struct {
+ Data []json.RawMessage `json:"data"`
+ }
+ if jsonErr := json.Unmarshal(body, &response); jsonErr != nil {
+ return nil, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode daily usage response")
+ }
+ return response.Data, nil
+}
+
+func parseMembersResponse(res *http.Response) ([]json.RawMessage, errors.Error) {
+ body, err := readResponseBody(res)
+ if err != nil {
+ return nil, err
+ }
+ var response struct {
+ TeamMembers []json.RawMessage `json:"teamMembers"`
+ }
+ if jsonErr := json.Unmarshal(body, &response); jsonErr != nil {
+ return nil, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode members response")
+ }
+ return response.TeamMembers, nil
+}
+
+func parseSpendMeta(res *http.Response) (int64, errors.Error) {
+ body, err := readResponseBody(res)
+ if err != nil {
+ return 0, err
+ }
+ var response struct {
+ SubscriptionCycleStart int64 `json:"subscriptionCycleStart"`
+ }
+ if jsonErr := json.Unmarshal(body, &response); jsonErr != nil {
+ return 0, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode spend metadata")
+ }
+ return response.SubscriptionCycleStart, nil
+}
+
+func parsePaginationBody(body []byte) (cursorPagination, errors.Error) {
+ var response struct {
+ Pagination cursorPagination `json:"pagination"`
+ }
+ if jsonErr := json.Unmarshal(body, &response); jsonErr != nil {
+ return cursorPagination{}, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode pagination")
+ }
+ return response.Pagination, nil
+}
+
+func parsePagination(res *http.Response) (cursorPagination, errors.Error) {
+ body, err := readResponseBody(res)
+ if err != nil {
+ return cursorPagination{}, err
+ }
+ return parsePaginationBody(body)
+}
+
+func readResponseBody(res *http.Response) ([]byte, errors.Error) {
+ if res == nil || res.Body == nil {
+ return nil, errors.Default.New("response body is nil")
+ }
+ defer res.Body.Close()
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, errors.Default.Wrap(err, "failed to read response body")
+ }
+ return body, nil
+}
+
+func parseEventTimestampMs(raw string) (time.Time, errors.Error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return time.Time{}, errors.BadInput.New("event timestamp is empty")
+ }
+ ms, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil {
+ return time.Time{}, errors.BadInput.Wrap(err, "invalid event timestamp")
+ }
+ return time.UnixMilli(ms).UTC(), nil
+}
+
+func computeEventId(timestamp, userEmail, conversationId, model string, chargedCents, requestsCosts float64) string {
+ payload := fmt.Sprintf("%s|%s|%s|%s|%v|%v", timestamp, userEmail, conversationId, model, chargedCents, requestsCosts)
+ sum := sha256.Sum256([]byte(payload))
+ return hex.EncodeToString(sum[:])
+}
+
+func normalizeNullableString(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" || strings.EqualFold(value, "null") {
+ return ""
+ }
+ return value
+}
+
+func billingCycleTime(ms int64) time.Time {
+ if ms <= 0 {
+ return time.Time{}
+ }
+ return time.UnixMilli(ms).UTC()
+}
diff --git a/backend/plugins/cursor/tasks/collector_utils_test.go b/backend/plugins/cursor/tasks/collector_utils_test.go
new file mode 100644
index 00000000000..7bb9f65d0a6
--- /dev/null
+++ b/backend/plugins/cursor/tasks/collector_utils_test.go
@@ -0,0 +1,107 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+func TestRawParamsFromTaskDataIncludesEndpoint(t *testing.T) {
+ taskData := &CursorTaskData{
+ Options: &CursorOptions{
+ ConnectionId: 1,
+ ScopeId: "team",
+ },
+ Connection: &models.CursorConnection{
+ CursorConn: models.CursorConn{
+ RestConnection: helper.RestConnection{Endpoint: models.DefaultEndpoint},
+ },
+ },
+ }
+
+ params := rawParamsFromTaskData(taskData)
+ encoded, err := json.Marshal(params.GetParams())
+ if err != nil {
+ t.Fatalf("marshal params: %v", err)
+ }
+
+ expected := `{"ConnectionId":1,"ScopeId":"team","Endpoint":"https://api.cursor.com"}`
+ if string(encoded) != expected {
+ t.Fatalf("raw params mismatch:\n got: %s\nwant: %s", encoded, expected)
+ }
+}
+
+func TestSplitDailyUsageTimeRangeMsChunksLongRanges(t *testing.T) {
+ start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ end := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
+ chunks := splitDailyUsageTimeRangeMs(start.UnixMilli(), end.UnixMilli(), cursorDailyUsageMaxDays)
+
+ if len(chunks) != 4 {
+ t.Fatalf("expected 4 chunks for ~90-day span, got %d", len(chunks))
+ }
+ if chunks[0].StartDateMs != start.UnixMilli() {
+ t.Fatalf("first chunk start mismatch: got %d want %d", chunks[0].StartDateMs, start.UnixMilli())
+ }
+ if chunks[len(chunks)-1].EndDateMs != end.UnixMilli() {
+ t.Fatalf("last chunk end mismatch: got %d want %d", chunks[len(chunks)-1].EndDateMs, end.UnixMilli())
+ }
+ for i := 1; i < len(chunks); i++ {
+ if chunks[i].StartDateMs <= chunks[i-1].StartDateMs {
+ t.Fatalf("chunk %d does not advance start time", i)
+ }
+ maxSpan := time.Duration(cursorDailyUsageMaxDays) * 24 * time.Hour
+ span := time.UnixMilli(chunks[i].StartDateMs).Sub(time.UnixMilli(chunks[i-1].StartDateMs))
+ if span > maxSpan+time.Millisecond {
+ t.Fatalf("gap between chunk %d and %d exceeds %d days", i-1, i, cursorDailyUsageMaxDays)
+ }
+ }
+}
+
+func TestSplitDailyUsageTimeRangeMsEmptyRange(t *testing.T) {
+ if chunks := splitDailyUsageTimeRangeMs(100, 100, cursorDailyUsageMaxDays); len(chunks) != 0 {
+ t.Fatalf("expected no chunks for empty range, got %d", len(chunks))
+ }
+}
+
+func TestNewUsageEventsDateRangeIteratorChunksLongRanges(t *testing.T) {
+ start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ end := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
+ chunks := splitDailyUsageTimeRangeMs(start.UnixMilli(), end.UnixMilli(), cursorDailyUsageMaxDays)
+
+ if len(chunks) != 4 {
+ t.Fatalf("expected 4 chunks for ~90-day usage-events span, got %d", len(chunks))
+ }
+}
+
+func TestDailyUsagePostBodyUsesEpochMilliseconds(t *testing.T) {
+ body := dailyUsagePostBody(&helper.RequestData{
+ Input: cursorTimeRangeInput{StartDateMs: 1710720000000, EndDateMs: 1710892800000},
+ Pager: &helper.Pager{Page: 1, Size: 100},
+ })
+ if body["startDate"] != int64(1710720000000) {
+ t.Fatalf("startDate should be epoch ms int64, got %#v", body["startDate"])
+ }
+ if body["endDate"] != int64(1710892800000) {
+ t.Fatalf("endDate should be epoch ms int64, got %#v", body["endDate"])
+ }
+}
diff --git a/backend/plugins/cursor/tasks/daily_usage_collector.go b/backend/plugins/cursor/tasks/daily_usage_collector.go
new file mode 100644
index 00000000000..61a73b68314
--- /dev/null
+++ b/backend/plugins/cursor/tasks/daily_usage_collector.go
@@ -0,0 +1,91 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+func newDailyUsageDateRangeIterator(since *time.Time) *helper.QueueIterator {
+ startMs, endMs := computeUsageTimeRangeMs(since, time.Now().UTC())
+ iter := helper.NewQueueIterator()
+ for _, chunk := range splitDailyUsageTimeRangeMs(startMs, endMs, cursorDailyUsageMaxDays) {
+ iter.Push(chunk)
+ }
+ return iter
+}
+
+func dailyUsagePostBody(reqData *helper.RequestData) map[string]interface{} {
+ input := reqData.Input.(cursorTimeRangeInput)
+ page := 1
+ if state, ok := reqData.CustomData.(cursorPageState); ok && state.Page > 0 {
+ page = state.Page
+ } else if reqData.Pager.Page > 0 {
+ page = reqData.Pager.Page
+ }
+ return map[string]interface{}{
+ "startDate": input.StartDateMs,
+ "endDate": input.EndDateMs,
+ "page": page,
+ "pageSize": reqData.Pager.Size,
+ }
+}
+
+// CollectDailyUsage collects per-user per-day adoption metrics from POST /teams/daily-usage-data.
+func CollectDailyUsage(taskCtx plugin.SubTaskContext) errors.Error {
+ data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData)
+ if !ok {
+ return errors.Default.New("task data is not CursorTaskData")
+ }
+ apiClient, err := CreateApiClient(taskCtx.TaskContext(), data.Connection)
+ if err != nil {
+ return err
+ }
+
+ rawArgs := helper.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Table: rawDailyUsageTable,
+ Options: rawParamsFromTaskData(data),
+ }
+
+ collector, err := helper.NewStatefulApiCollector(rawArgs)
+ if err != nil {
+ return err
+ }
+
+ err = collector.InitCollector(helper.ApiCollectorArgs{
+ ApiClient: apiClient,
+ Input: newDailyUsageDateRangeIterator(collector.GetSince()),
+ Method: http.MethodPost,
+ UrlTemplate: "teams/daily-usage-data",
+ PageSize: cursorApiPageSize,
+ RequestBody: dailyUsagePostBody,
+ GetNextPageCustomData: nextPostPage,
+ ResponseParser: parseDailyUsageResponse,
+ })
+ if err != nil {
+ return err
+ }
+
+ return collector.Execute()
+}
diff --git a/backend/plugins/cursor/tasks/daily_usage_extractor.go b/backend/plugins/cursor/tasks/daily_usage_extractor.go
new file mode 100644
index 00000000000..1a493f01d64
--- /dev/null
+++ b/backend/plugins/cursor/tasks/daily_usage_extractor.go
@@ -0,0 +1,113 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "strings"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+type dailyUsageRecord struct {
+ Day string `json:"day"`
+ UserId string `json:"userId"`
+ Email string `json:"email"`
+ IsActive bool `json:"isActive"`
+ Completions int `json:"completions"`
+ PremiumRequests int `json:"premiumRequests"`
+ AgentRequests int `json:"agentRequests"`
+ ChatRequests int `json:"chatRequests"`
+ ComposerRequests int `json:"composerRequests"`
+ TotalTabsAccepted int `json:"totalTabsAccepted"`
+ TotalTabsShown int `json:"totalTabsShown"`
+ UsageBasedReqs int `json:"usageBasedReqs"`
+ SubscriptionIncludedReqs int `json:"subscriptionIncludedReqs"`
+ MostUsedModel string `json:"mostUsedModel"`
+ ClientVersion string `json:"clientVersion"`
+ TotalLinesAdded int `json:"totalLinesAdded"`
+ TotalLinesDeleted int `json:"totalLinesDeleted"`
+ AcceptedLinesAdded int `json:"acceptedLinesAdded"`
+ AcceptedLinesDeleted int `json:"acceptedLinesDeleted"`
+ TotalApplies int `json:"totalApplies"`
+ TotalAccepts int `json:"totalAccepts"`
+ TotalRejects int `json:"totalRejects"`
+}
+
+// ExtractDailyUsage parses raw daily usage records into tool-layer tables.
+func ExtractDailyUsage(taskCtx plugin.SubTaskContext) errors.Error {
+ data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData)
+ if !ok {
+ return errors.Default.New("task data is not CursorTaskData")
+ }
+
+ extractor, err := helper.NewStatefulApiExtractor(&helper.StatefulApiExtractorArgs[dailyUsageRecord]{
+ SubtaskCommonArgs: cursorSubtaskCommonArgs(taskCtx, data, rawDailyUsageTable),
+ Extract: func(record *dailyUsageRecord, _ *helper.RawData) ([]any, errors.Error) {
+ userId := strings.TrimSpace(record.UserId)
+ if userId == "" {
+ userId = strings.TrimSpace(record.Email)
+ }
+ if userId == "" {
+ return nil, nil
+ }
+
+ usageDate, parseErr := time.Parse("2006-01-02", strings.TrimSpace(record.Day))
+ if parseErr != nil {
+ return nil, errors.BadInput.Wrap(parseErr, "invalid day format in daily usage record")
+ }
+
+ usage := &models.CursorDailyUsage{
+ ConnectionId: data.Options.ConnectionId,
+ ScopeId: data.Options.ScopeId,
+ UserId: userId,
+ UsageDate: usageDate,
+ Email: strings.TrimSpace(record.Email),
+ IsActive: record.IsActive,
+ Completions: record.Completions,
+ PremiumRequests: record.PremiumRequests,
+ AgentRequests: record.AgentRequests,
+ ChatRequests: record.ChatRequests,
+ ComposerRequests: record.ComposerRequests,
+ TabsAccepted: record.TotalTabsAccepted,
+ TabsShown: record.TotalTabsShown,
+ UsageBasedReqs: record.UsageBasedReqs,
+ SubscriptionIncludedReqs: record.SubscriptionIncludedReqs,
+ MostUsedModel: strings.TrimSpace(record.MostUsedModel),
+ ClientVersion: strings.TrimSpace(record.ClientVersion),
+ AcceptedLinesAdded: record.AcceptedLinesAdded,
+ AcceptedLinesDeleted: record.AcceptedLinesDeleted,
+ TotalLinesAdded: record.TotalLinesAdded,
+ TotalLinesDeleted: record.TotalLinesDeleted,
+ TotalApplies: record.TotalApplies,
+ TotalAccepts: record.TotalAccepts,
+ TotalRejects: record.TotalRejects,
+ LinesAdded: record.AcceptedLinesAdded,
+ LinesDeleted: record.AcceptedLinesDeleted,
+ }
+ return []any{usage}, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return extractor.Execute()
+}
diff --git a/backend/plugins/cursor/tasks/extract_utils.go b/backend/plugins/cursor/tasks/extract_utils.go
new file mode 100644
index 00000000000..c755b526960
--- /dev/null
+++ b/backend/plugins/cursor/tasks/extract_utils.go
@@ -0,0 +1,38 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+// cursorExtractorVersion bumps when extract logic changes in a way that requires
+// a one-time full re-extract (SubtaskStateManager compares SubtaskConfig).
+const cursorExtractorVersion = 2
+
+func cursorSubtaskCommonArgs(taskCtx plugin.SubTaskContext, data *CursorTaskData, rawTable string) *helper.SubtaskCommonArgs {
+ return &helper.SubtaskCommonArgs{
+ SubTaskContext: taskCtx,
+ Table: rawTable,
+ Params: rawParamsFromTaskData(data),
+ SubtaskConfig: map[string]any{
+ "extractorVersion": cursorExtractorVersion,
+ },
+ }
+}
diff --git a/backend/plugins/cursor/tasks/members_collector.go b/backend/plugins/cursor/tasks/members_collector.go
new file mode 100644
index 00000000000..1e741096358
--- /dev/null
+++ b/backend/plugins/cursor/tasks/members_collector.go
@@ -0,0 +1,55 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+// CollectMembers collects team member roster from GET /teams/members.
+func CollectMembers(taskCtx plugin.SubTaskContext) errors.Error {
+ data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData)
+ if !ok {
+ return errors.Default.New("task data is not CursorTaskData")
+ }
+ apiClient, err := CreateApiClient(taskCtx.TaskContext(), data.Connection)
+ if err != nil {
+ return err
+ }
+
+ rawArgs := helper.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Table: rawMembersTable,
+ Options: rawParamsFromTaskData(data),
+ }
+
+ collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{
+ RawDataSubTaskArgs: rawArgs,
+ ApiClient: apiClient,
+ PageSize: 0,
+ UrlTemplate: "teams/members",
+ ResponseParser: parseMembersResponse,
+ })
+ if err != nil {
+ return err
+ }
+
+ return collector.Execute()
+}
diff --git a/backend/plugins/cursor/tasks/members_extractor.go b/backend/plugins/cursor/tasks/members_extractor.go
new file mode 100644
index 00000000000..e541b2f3af1
--- /dev/null
+++ b/backend/plugins/cursor/tasks/members_extractor.go
@@ -0,0 +1,81 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+type memberRecord struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Id string `json:"id"`
+ Role string `json:"role"`
+ IsRemoved bool `json:"isRemoved"`
+}
+
+// ExtractMembers parses raw member records into tool-layer tables.
+func ExtractMembers(taskCtx plugin.SubTaskContext) errors.Error {
+ data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData)
+ if !ok {
+ return errors.Default.New("task data is not CursorTaskData")
+ }
+
+ extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
+ RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Table: rawMembersTable,
+ Options: rawParamsFromTaskData(data),
+ },
+ Extract: func(row *helper.RawData) ([]interface{}, errors.Error) {
+ var record memberRecord
+ if err := errors.Convert(json.Unmarshal(row.Data, &record)); err != nil {
+ return nil, err
+ }
+
+ userId := strings.TrimSpace(record.Id)
+ if userId == "" {
+ userId = strings.TrimSpace(record.Email)
+ }
+ if userId == "" {
+ return nil, nil
+ }
+
+ member := &models.CursorMember{
+ ConnectionId: data.Options.ConnectionId,
+ ScopeId: data.Options.ScopeId,
+ UserId: userId,
+ Email: strings.TrimSpace(record.Email),
+ Name: strings.TrimSpace(record.Name),
+ Role: strings.TrimSpace(record.Role),
+ IsRemoved: record.IsRemoved,
+ }
+ return []interface{}{member}, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return extractor.Execute()
+}
diff --git a/backend/plugins/cursor/tasks/options.go b/backend/plugins/cursor/tasks/options.go
new file mode 100644
index 00000000000..3676db9443a
--- /dev/null
+++ b/backend/plugins/cursor/tasks/options.go
@@ -0,0 +1,24 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+// CursorOptions defines task-level options passed from pipeline plans.
+type CursorOptions struct {
+ ConnectionId uint64 `mapstructure:"connectionId" json:"connectionId"`
+ ScopeId string `mapstructure:"scopeId" json:"scopeId"`
+}
diff --git a/backend/plugins/cursor/tasks/register.go b/backend/plugins/cursor/tasks/register.go
new file mode 100644
index 00000000000..e8a3f4ec3cb
--- /dev/null
+++ b/backend/plugins/cursor/tasks/register.go
@@ -0,0 +1,34 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import "github.com/apache/incubator-devlake/core/plugin"
+
+// GetSubTaskMetas returns the ordered list of Cursor subtasks.
+func GetSubTaskMetas() []plugin.SubTaskMeta {
+ return []plugin.SubTaskMeta{
+ CollectMembersMeta,
+ ExtractMembersMeta,
+ CollectUsageEventsMeta,
+ ExtractUsageEventsMeta,
+ CollectUserSpendMeta,
+ ExtractUserSpendMeta,
+ CollectDailyUsageMeta,
+ ExtractDailyUsageMeta,
+ }
+}
diff --git a/backend/plugins/cursor/tasks/retry_after.go b/backend/plugins/cursor/tasks/retry_after.go
new file mode 100644
index 00000000000..4e8b2357730
--- /dev/null
+++ b/backend/plugins/cursor/tasks/retry_after.go
@@ -0,0 +1,79 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/log"
+)
+
+func handleCursorRetryAfter(res *http.Response, logger log.Logger, now nowFunc, sleep sleepFunc) errors.Error {
+ if res == nil || res.StatusCode != http.StatusTooManyRequests {
+ return nil
+ }
+ if now == nil {
+ now = time.Now
+ }
+ if sleep == nil {
+ sleep = time.Sleep
+ }
+ wait := parseRetryAfter(res.Header.Get("Retry-After"), now().UTC())
+ if wait > 0 && logger != nil {
+ logger.Warn(nil, "Cursor returned 429; sleeping %s per Retry-After", wait.String())
+ }
+ if wait > 0 {
+ sleep(wait)
+ }
+ return errors.HttpStatus(http.StatusTooManyRequests).New("Cursor rate limited the request")
+}
+
+func parseRetryAfter(value string, now time.Time) time.Duration {
+ value = trimSpace(value)
+ if value == "" {
+ return 0
+ }
+ if seconds, err := strconv.Atoi(value); err == nil {
+ return time.Duration(seconds) * time.Second
+ }
+ if retryAt, err := http.ParseTime(value); err == nil {
+ wait := retryAt.Sub(now)
+ if wait > 0 {
+ return wait
+ }
+ }
+ return 0
+}
+
+func trimSpace(s string) string {
+ start := 0
+ for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
+ start++
+ }
+ end := len(s)
+ for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
+ end--
+ }
+ return s[start:end]
+}
+
+type nowFunc func() time.Time
+type sleepFunc func(time.Duration)
diff --git a/backend/plugins/cursor/tasks/subtasks.go b/backend/plugins/cursor/tasks/subtasks.go
new file mode 100644
index 00000000000..8d9462f5232
--- /dev/null
+++ b/backend/plugins/cursor/tasks/subtasks.go
@@ -0,0 +1,88 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import "github.com/apache/incubator-devlake/core/plugin"
+
+var CollectUsageEventsMeta = plugin.SubTaskMeta{
+ Name: "collectUsageEvents",
+ EntryPoint: CollectUsageEvents,
+ EnabledByDefault: true,
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
+ Description: "Collect usage events from the Cursor Admin API",
+}
+
+var ExtractUsageEventsMeta = plugin.SubTaskMeta{
+ Name: "extractUsageEvents",
+ EntryPoint: ExtractUsageEvents,
+ EnabledByDefault: true,
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
+ Description: "Extract usage events into tool-layer tables",
+ Dependencies: []*plugin.SubTaskMeta{&CollectUsageEventsMeta},
+}
+
+var CollectUserSpendMeta = plugin.SubTaskMeta{
+ Name: "collectUserSpend",
+ EntryPoint: CollectUserSpend,
+ EnabledByDefault: true,
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
+ Description: "Collect per-user billing cycle spend from the Cursor Admin API",
+}
+
+var ExtractUserSpendMeta = plugin.SubTaskMeta{
+ Name: "extractUserSpend",
+ EntryPoint: ExtractUserSpend,
+ EnabledByDefault: true,
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
+ Description: "Extract per-user billing cycle spend into tool-layer tables",
+ Dependencies: []*plugin.SubTaskMeta{&CollectUserSpendMeta},
+}
+
+var CollectMembersMeta = plugin.SubTaskMeta{
+ Name: "collectMembers",
+ EntryPoint: CollectMembers,
+ EnabledByDefault: true,
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
+ Description: "Collect team member roster from the Cursor Admin API",
+}
+
+var ExtractMembersMeta = plugin.SubTaskMeta{
+ Name: "extractMembers",
+ EntryPoint: ExtractMembers,
+ EnabledByDefault: true,
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
+ Description: "Extract team member roster into tool-layer tables",
+ Dependencies: []*plugin.SubTaskMeta{&CollectMembersMeta},
+}
+
+var CollectDailyUsageMeta = plugin.SubTaskMeta{
+ Name: "collectDailyUsage",
+ EntryPoint: CollectDailyUsage,
+ EnabledByDefault: true,
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
+ Description: "Collect per-user per-day adoption metrics from the Cursor Admin API",
+}
+
+var ExtractDailyUsageMeta = plugin.SubTaskMeta{
+ Name: "extractDailyUsage",
+ EntryPoint: ExtractDailyUsage,
+ EnabledByDefault: true,
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
+ Description: "Extract per-user per-day adoption metrics into tool-layer tables",
+ Dependencies: []*plugin.SubTaskMeta{&CollectDailyUsageMeta},
+}
diff --git a/backend/plugins/cursor/tasks/task_data.go b/backend/plugins/cursor/tasks/task_data.go
new file mode 100644
index 00000000000..6d0cb9a7aa5
--- /dev/null
+++ b/backend/plugins/cursor/tasks/task_data.go
@@ -0,0 +1,26 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import "github.com/apache/incubator-devlake/plugins/cursor/models"
+
+// CursorTaskData stores runtime dependencies for subtasks.
+type CursorTaskData struct {
+ Options *CursorOptions
+ Connection *models.CursorConnection
+}
diff --git a/backend/plugins/cursor/tasks/usage_events_collector.go b/backend/plugins/cursor/tasks/usage_events_collector.go
new file mode 100644
index 00000000000..fe5a04e68b9
--- /dev/null
+++ b/backend/plugins/cursor/tasks/usage_events_collector.go
@@ -0,0 +1,108 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+func newUsageEventsDateRangeIterator(since *time.Time) *helper.QueueIterator {
+ startMs, endMs := computeUsageTimeRangeMs(since, time.Now().UTC())
+ iter := helper.NewQueueIterator()
+ for _, chunk := range splitDailyUsageTimeRangeMs(startMs, endMs, cursorDailyUsageMaxDays) {
+ iter.Push(chunk)
+ }
+ return iter
+}
+
+func nextPostPage(prevReqData *helper.RequestData, prevPageResponse *http.Response) (interface{}, errors.Error) {
+ pagination, err := parsePagination(prevPageResponse)
+ if err != nil {
+ return nil, err
+ }
+ if !pagination.HasNextPage {
+ return nil, helper.ErrFinishCollect
+ }
+ currentPage := 1
+ if state, ok := prevReqData.CustomData.(cursorPageState); ok && state.Page > 0 {
+ currentPage = state.Page
+ } else if prevReqData.Pager.Page > 0 {
+ currentPage = prevReqData.Pager.Page
+ }
+ return cursorPageState{Page: currentPage + 1}, nil
+}
+
+func postPageBody(reqData *helper.RequestData) map[string]interface{} {
+ input := reqData.Input.(cursorTimeRangeInput)
+ page := 1
+ if state, ok := reqData.CustomData.(cursorPageState); ok && state.Page > 0 {
+ page = state.Page
+ } else if reqData.Pager.Page > 0 {
+ page = reqData.Pager.Page
+ }
+ return map[string]interface{}{
+ "startDate": input.StartDateMs,
+ "endDate": input.EndDateMs,
+ "page": page,
+ "pageSize": reqData.Pager.Size,
+ }
+}
+
+// CollectUsageEvents collects granular usage events from POST /teams/filtered-usage-events.
+func CollectUsageEvents(taskCtx plugin.SubTaskContext) errors.Error {
+ data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData)
+ if !ok {
+ return errors.Default.New("task data is not CursorTaskData")
+ }
+ apiClient, err := CreateApiClient(taskCtx.TaskContext(), data.Connection)
+ if err != nil {
+ return err
+ }
+
+ rawArgs := helper.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Table: rawUsageEventsTable,
+ Options: rawParamsFromTaskData(data),
+ }
+
+ collector, err := helper.NewStatefulApiCollector(rawArgs)
+ if err != nil {
+ return err
+ }
+
+ err = collector.InitCollector(helper.ApiCollectorArgs{
+ ApiClient: apiClient,
+ Input: newUsageEventsDateRangeIterator(collector.GetSince()),
+ Method: http.MethodPost,
+ UrlTemplate: "teams/filtered-usage-events",
+ PageSize: cursorApiPageSize,
+ RequestBody: postPageBody,
+ GetNextPageCustomData: nextPostPage,
+ ResponseParser: parseUsageEventsResponse,
+ })
+ if err != nil {
+ return err
+ }
+
+ return collector.Execute()
+}
diff --git a/backend/plugins/cursor/tasks/usage_events_extractor.go b/backend/plugins/cursor/tasks/usage_events_extractor.go
new file mode 100644
index 00000000000..25ee1071741
--- /dev/null
+++ b/backend/plugins/cursor/tasks/usage_events_extractor.go
@@ -0,0 +1,108 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "strings"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+type usageEventRecord struct {
+ Timestamp string `json:"timestamp"`
+ Model string `json:"model"`
+ Kind string `json:"kind"`
+ MaxMode bool `json:"maxMode"`
+ RequestsCosts float64 `json:"requestsCosts"`
+ IsTokenBasedCall bool `json:"isTokenBasedCall"`
+ TokenUsage *usageTokenUsage `json:"tokenUsage"`
+ UserEmail string `json:"userEmail"`
+ IsChargeable bool `json:"isChargeable"`
+ ServiceAccountId string `json:"serviceAccountId"`
+ IsHeadless bool `json:"isHeadless"`
+ ChargedCents float64 `json:"chargedCents"`
+ CursorTokenFee float64 `json:"cursorTokenFee"`
+ HostingType string `json:"hostingType"`
+ ConversationId string `json:"conversationId"`
+}
+
+type usageTokenUsage struct {
+ InputTokens int `json:"inputTokens"`
+ OutputTokens int `json:"outputTokens"`
+ CacheWriteTokens int `json:"cacheWriteTokens"`
+ CacheReadTokens int `json:"cacheReadTokens"`
+ TotalCents float64 `json:"totalCents"`
+}
+
+// ExtractUsageEvents parses raw usage event records into tool-layer tables.
+func ExtractUsageEvents(taskCtx plugin.SubTaskContext) errors.Error {
+ data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData)
+ if !ok {
+ return errors.Default.New("task data is not CursorTaskData")
+ }
+
+ extractor, err := helper.NewStatefulApiExtractor(&helper.StatefulApiExtractorArgs[usageEventRecord]{
+ SubtaskCommonArgs: cursorSubtaskCommonArgs(taskCtx, data, rawUsageEventsTable),
+ Extract: func(record *usageEventRecord, _ *helper.RawData) ([]any, errors.Error) {
+ eventTime, err := parseEventTimestampMs(record.Timestamp)
+ if err != nil {
+ return nil, err
+ }
+
+ userEmail := strings.TrimSpace(record.UserEmail)
+ if userEmail == "" {
+ return nil, nil
+ }
+
+ event := &models.CursorUsageEvent{
+ ConnectionId: data.Options.ConnectionId,
+ ScopeId: data.Options.ScopeId,
+ EventId: computeEventId(record.Timestamp, userEmail, record.ConversationId, record.Model, record.ChargedCents, record.RequestsCosts),
+ EventTime: eventTime,
+ UserEmail: userEmail,
+ Model: strings.TrimSpace(record.Model),
+ Kind: strings.TrimSpace(record.Kind),
+ ConversationId: strings.TrimSpace(record.ConversationId),
+ ChargedCents: record.ChargedCents,
+ RequestsCosts: record.RequestsCosts,
+ IsTokenBasedCall: record.IsTokenBasedCall,
+ IsChargeable: record.IsChargeable,
+ MaxMode: record.MaxMode,
+ IsHeadless: record.IsHeadless,
+ CursorTokenFee: record.CursorTokenFee,
+ HostingType: strings.TrimSpace(record.HostingType),
+ ServiceAccountId: normalizeNullableString(record.ServiceAccountId),
+ }
+ if record.TokenUsage != nil {
+ event.InputTokens = record.TokenUsage.InputTokens
+ event.OutputTokens = record.TokenUsage.OutputTokens
+ event.CacheReadTokens = record.TokenUsage.CacheReadTokens
+ event.CacheWriteTokens = record.TokenUsage.CacheWriteTokens
+ event.TotalCents = record.TokenUsage.TotalCents
+ }
+ return []any{event}, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return extractor.Execute()
+}
diff --git a/backend/plugins/cursor/tasks/usage_events_extractor_test.go b/backend/plugins/cursor/tasks/usage_events_extractor_test.go
new file mode 100644
index 00000000000..51d62f1a1e0
--- /dev/null
+++ b/backend/plugins/cursor/tasks/usage_events_extractor_test.go
@@ -0,0 +1,65 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestUsageEventRecordUnmarshalFractionalRequestsCosts(t *testing.T) {
+ raw := []byte(`{
+ "timestamp":"1783617259310",
+ "model":"composer-2.5-fast",
+ "kind":"Usage-based",
+ "requestsCosts":3.8,
+ "userEmail":"user@example.com",
+ "chargedCents":8,
+ "conversationId":"846952e0-0221-473b-bb1d-e5d016f071b1"
+ }`)
+
+ var record usageEventRecord
+ if err := json.Unmarshal(raw, &record); err != nil {
+ t.Fatalf("unmarshal usage event: %v", err)
+ }
+ if record.RequestsCosts != 3.8 {
+ t.Fatalf("requestsCosts = %v, want 3.8", record.RequestsCosts)
+ }
+}
+
+func TestComputeEventIdWithFractionalRequestsCosts(t *testing.T) {
+ fractional := computeEventId(
+ "1783617259310",
+ "user@example.com",
+ "846952e0-0221-473b-bb1d-e5d016f071b1",
+ "composer-2.5-fast",
+ 8,
+ 3.8,
+ )
+ integer := computeEventId(
+ "1783617259310",
+ "user@example.com",
+ "846952e0-0221-473b-bb1d-e5d016f071b1",
+ "composer-2.5-fast",
+ 8,
+ 2,
+ )
+ if fractional == integer {
+ t.Fatal("expected fractional requestsCosts to change event id")
+ }
+}
diff --git a/backend/plugins/cursor/tasks/user_spend_collector.go b/backend/plugins/cursor/tasks/user_spend_collector.go
new file mode 100644
index 00000000000..9d619606d3f
--- /dev/null
+++ b/backend/plugins/cursor/tasks/user_spend_collector.go
@@ -0,0 +1,127 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+type cursorSpendCollectInput struct {
+ CollectedAt time.Time `json:"collectedAt"`
+}
+
+type spendRawRecord struct {
+ SubscriptionCycleStart int64 `json:"subscriptionCycleStart"`
+ CollectedAt time.Time `json:"collectedAt"`
+ Member json.RawMessage `json:"member"`
+}
+
+func newSpendIterator() *helper.QueueIterator {
+ iter := helper.NewQueueIterator()
+ iter.Push(cursorSpendCollectInput{CollectedAt: time.Now().UTC()})
+ return iter
+}
+
+func parseSpendPageResponse(res *http.Response) ([]json.RawMessage, errors.Error) {
+ if res == nil || res.Body == nil {
+ return nil, errors.Default.New("response body is nil")
+ }
+ body, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ if err != nil {
+ return nil, errors.Default.Wrap(err, "failed to read spend response")
+ }
+
+ var response struct {
+ SubscriptionCycleStart int64 `json:"subscriptionCycleStart"`
+ TeamMemberSpend []json.RawMessage `json:"teamMemberSpend"`
+ }
+ if jsonErr := json.Unmarshal(body, &response); jsonErr != nil {
+ return nil, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode spend response")
+ }
+
+ collectedAt := time.Now().UTC()
+ results := make([]json.RawMessage, 0, len(response.TeamMemberSpend))
+ for _, member := range response.TeamMemberSpend {
+ wrapped, marshalErr := json.Marshal(spendRawRecord{
+ SubscriptionCycleStart: response.SubscriptionCycleStart,
+ CollectedAt: collectedAt,
+ Member: member,
+ })
+ if marshalErr != nil {
+ return nil, errors.Default.Wrap(errors.Convert(marshalErr), "failed to wrap spend record")
+ }
+ results = append(results, wrapped)
+ }
+ return results, nil
+}
+
+func spendPageBody(reqData *helper.RequestData) map[string]interface{} {
+ page := 1
+ if state, ok := reqData.CustomData.(cursorPageState); ok && state.Page > 0 {
+ page = state.Page
+ } else if reqData.Pager.Page > 0 {
+ page = reqData.Pager.Page
+ }
+ return map[string]interface{}{
+ "page": page,
+ "pageSize": reqData.Pager.Size,
+ }
+}
+
+// CollectUserSpend collects per-user billing cycle spend from POST /teams/spend.
+func CollectUserSpend(taskCtx plugin.SubTaskContext) errors.Error {
+ data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData)
+ if !ok {
+ return errors.Default.New("task data is not CursorTaskData")
+ }
+ apiClient, err := CreateApiClient(taskCtx.TaskContext(), data.Connection)
+ if err != nil {
+ return err
+ }
+
+ rawArgs := helper.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Table: rawUserSpendTable,
+ Options: rawParamsFromTaskData(data),
+ }
+
+ collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{
+ RawDataSubTaskArgs: rawArgs,
+ ApiClient: apiClient,
+ Input: newSpendIterator(),
+ Method: http.MethodPost,
+ UrlTemplate: "teams/spend",
+ PageSize: cursorApiPageSize,
+ RequestBody: spendPageBody,
+ GetNextPageCustomData: nextPostPage,
+ ResponseParser: parseSpendPageResponse,
+ })
+ if err != nil {
+ return err
+ }
+
+ return collector.Execute()
+}
diff --git a/backend/plugins/cursor/tasks/user_spend_extractor.go b/backend/plugins/cursor/tasks/user_spend_extractor.go
new file mode 100644
index 00000000000..8f705ada864
--- /dev/null
+++ b/backend/plugins/cursor/tasks/user_spend_extractor.go
@@ -0,0 +1,96 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/cursor/models"
+)
+
+type spendMemberRecord struct {
+ UserId string `json:"userId"`
+ Email string `json:"email"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ SpendCents float64 `json:"spendCents"`
+ IncludedSpendCents float64 `json:"includedSpendCents"`
+ FastPremiumRequests int `json:"fastPremiumRequests"`
+ MonthlyLimitDollars float64 `json:"monthlyLimitDollars"`
+ HardLimitOverrideDollars float64 `json:"hardLimitOverrideDollars"`
+}
+
+// ExtractUserSpend parses raw spend records into tool-layer tables.
+func ExtractUserSpend(taskCtx plugin.SubTaskContext) errors.Error {
+ data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData)
+ if !ok {
+ return errors.Default.New("task data is not CursorTaskData")
+ }
+
+ extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
+ RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Table: rawUserSpendTable,
+ Options: rawParamsFromTaskData(data),
+ },
+ Extract: func(row *helper.RawData) ([]interface{}, errors.Error) {
+ var wrapped spendRawRecord
+ if err := errors.Convert(json.Unmarshal(row.Data, &wrapped)); err != nil {
+ return nil, err
+ }
+
+ var record spendMemberRecord
+ if err := errors.Convert(json.Unmarshal(wrapped.Member, &record)); err != nil {
+ return nil, err
+ }
+
+ userId := strings.TrimSpace(record.UserId)
+ if userId == "" {
+ userId = strings.TrimSpace(record.Email)
+ }
+ if userId == "" {
+ return nil, nil
+ }
+
+ spend := &models.CursorUserSpend{
+ ConnectionId: data.Options.ConnectionId,
+ ScopeId: data.Options.ScopeId,
+ UserId: userId,
+ BillingCycleStart: billingCycleTime(wrapped.SubscriptionCycleStart),
+ CollectedAt: wrapped.CollectedAt,
+ Email: strings.TrimSpace(record.Email),
+ Name: strings.TrimSpace(record.Name),
+ Role: strings.TrimSpace(record.Role),
+ SpendCents: record.SpendCents,
+ IncludedSpendCents: record.IncludedSpendCents,
+ FastPremiumRequests: record.FastPremiumRequests,
+ MonthlyLimitDollars: record.MonthlyLimitDollars,
+ HardLimitOverrideDollars: record.HardLimitOverrideDollars,
+ }
+ return []interface{}{spend}, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return extractor.Execute()
+}
diff --git a/backend/plugins/table_info_test.go b/backend/plugins/table_info_test.go
index 0d4482ca6c9..181294722ea 100644
--- a/backend/plugins/table_info_test.go
+++ b/backend/plugins/table_info_test.go
@@ -30,6 +30,7 @@ import (
bitbucket_server "github.com/apache/incubator-devlake/plugins/bitbucket_server/impl"
circleci "github.com/apache/incubator-devlake/plugins/circleci/impl"
claudeCode "github.com/apache/incubator-devlake/plugins/claude_code/impl"
+ cursor "github.com/apache/incubator-devlake/plugins/cursor/impl"
customize "github.com/apache/incubator-devlake/plugins/customize/impl"
dbt "github.com/apache/incubator-devlake/plugins/dbt/impl"
dora "github.com/apache/incubator-devlake/plugins/dora/impl"
@@ -106,6 +107,7 @@ func Test_GetPluginTablesInfo(t *testing.T) {
checker.FeedIn("webhook/models", webhook.Webhook{}.GetTablesInfo)
checker.FeedIn("zentao/models", zentao.Zentao{}.GetTablesInfo)
checker.FeedIn("claude_code/models", claudeCode.ClaudeCode{}.GetTablesInfo)
+ checker.FeedIn("cursor/models", cursor.Cursor{}.GetTablesInfo)
checker.FeedIn("circleci/models", circleci.Circleci{}.GetTablesInfo)
checker.FeedIn("opsgenie/models", opsgenie.Opsgenie{}.GetTablesInfo)
checker.FeedIn("linker/models", linker.Linker{}.GetTablesInfo)
diff --git a/backend/scripts/build-plugins.sh b/backend/scripts/build-plugins.sh
index 03fb7b4f9fb..c9ae1574eba 100755
--- a/backend/scripts/build-plugins.sh
+++ b/backend/scripts/build-plugins.sh
@@ -64,6 +64,22 @@ fi
PIDS=""
+# Cap concurrent CGO plugin builds. Unbounded nproc OOMs Docker Desktop / small VMs.
+# Override with DEVLAKE_PLUGIN_PARALLELISM=N (default: min(4, nproc)).
+if [ -n "$DEVLAKE_PLUGIN_PARALLELISM" ]; then
+ PARALLELISM="$DEVLAKE_PLUGIN_PARALLELISM"
+else
+ PARALLELISM=4
+ if command -v nproc >/dev/null 2>&1; then
+ PARALLELISM=$(nproc)
+ elif command -v sysctl >/dev/null 2>&1; then
+ PARALLELISM=$(sysctl -n hw.ncpu)
+ fi
+ if [ "$PARALLELISM" -gt 4 ]; then
+ PARALLELISM=4
+ fi
+fi
+echo "Plugin build parallelism: $PARALLELISM"
for PLUG in $PLUGINS; do
NAME=$(basename $PLUG)
echo "Building plugin $NAME to bin/plugins/$NAME/$NAME.so with args: $* --gcflags="$GCFLAGS""
@@ -71,12 +87,6 @@ for PLUG in $PLUGINS; do
PIDS="$PIDS $!"
# avoid too many processes causing signal killed
COUNT=$(echo "$PIDS" | wc -w)
- PARALLELISM=4
- if command -v nproc >/dev/null 2>&1; then
- PARALLELISM=$(nproc)
- elif command -v sysctl >/dev/null 2>&1; then
- PARALLELISM=$(sysctl -n hw.ncpu)
- fi
if [ "$COUNT" -ge "$PARALLELISM" ]; then
for PID in $PIDS; do
wait $PID
diff --git a/config-ui/src/plugins/register/cursor/assets/icon.svg b/config-ui/src/plugins/register/cursor/assets/icon.svg
new file mode 100644
index 00000000000..cbd7c824435
--- /dev/null
+++ b/config-ui/src/plugins/register/cursor/assets/icon.svg
@@ -0,0 +1,22 @@
+
+
diff --git a/config-ui/src/plugins/register/cursor/config.tsx b/config-ui/src/plugins/register/cursor/config.tsx
new file mode 100644
index 00000000000..077a780a2fe
--- /dev/null
+++ b/config-ui/src/plugins/register/cursor/config.tsx
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { IPluginConfig } from '@/types';
+
+import Icon from './assets/icon.svg?react';
+import { Token } from './connection-fields';
+
+export const CursorConfig: IPluginConfig = {
+ plugin: 'cursor',
+ name: 'Cursor',
+ icon: ({ color }) => ,
+ sort: 6.7,
+ isBeta: true,
+ connection: {
+ docLink: 'https://cursor.com/docs/account/teams/admin-api',
+ initialValues: {
+ endpoint: 'https://api.cursor.com',
+ token: '',
+ rateLimitPerHour: 1200,
+ },
+ fields: [
+ 'name',
+ 'endpoint',
+ ({ type, initialValues, values, setValues, setErrors }: any) => (
+
+ ),
+ 'proxy',
+ {
+ key: 'rateLimitPerHour',
+ subLabel:
+ 'By default, DevLake uses 1,200 requests/hour for Cursor Admin API collection (documented limit is 20 requests/minute). Adjust this value to throttle collection speed.',
+ defaultValue: 1200,
+ },
+ ],
+ },
+ dataScope: {
+ title: 'Team',
+ },
+ scopeConfig: {
+ entities: ['CROSS'],
+ transformation: {},
+ },
+};
diff --git a/config-ui/src/plugins/register/cursor/connection-fields/index.ts b/config-ui/src/plugins/register/cursor/connection-fields/index.ts
new file mode 100644
index 00000000000..9c58cfcc30a
--- /dev/null
+++ b/config-ui/src/plugins/register/cursor/connection-fields/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+export * from './token';
diff --git a/config-ui/src/plugins/register/cursor/connection-fields/styled.ts b/config-ui/src/plugins/register/cursor/connection-fields/styled.ts
new file mode 100644
index 00000000000..82ba9f717d0
--- /dev/null
+++ b/config-ui/src/plugins/register/cursor/connection-fields/styled.ts
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import styled from 'styled-components';
+
+export const ErrorText = styled.div`
+ margin-top: 4px;
+ color: #f5222d;
+ font-size: 12px;
+`;
diff --git a/config-ui/src/plugins/register/cursor/connection-fields/token.tsx b/config-ui/src/plugins/register/cursor/connection-fields/token.tsx
new file mode 100644
index 00000000000..5511bab4417
--- /dev/null
+++ b/config-ui/src/plugins/register/cursor/connection-fields/token.tsx
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { useEffect, useMemo, type ChangeEvent } from 'react';
+import { Input } from 'antd';
+
+import { Block } from '@/components';
+
+import * as S from './styled';
+
+interface Props {
+ type: 'create' | 'update';
+ initialValues: any;
+ values: any;
+ setValues: (value: any) => void;
+ setErrors: (value: any) => void;
+}
+
+export const Token = ({ type, initialValues, values, setValues, setErrors }: Props) => {
+ useEffect(() => {
+ setValues({ token: type === 'create' ? initialValues.token ?? '' : undefined });
+ }, [type, initialValues.token]);
+
+ const error = useMemo(() => {
+ if (type === 'update') return '';
+ return values.token?.trim() ? '' : 'Team Admin API Key is required';
+ }, [type, values.token]);
+
+ useEffect(() => {
+ setErrors({ token: error });
+ }, [error]);
+
+ return (
+
+ ) => setValues({ token: e.target.value })}
+ />
+ {error && {error}}
+
+ );
+};
diff --git a/config-ui/src/plugins/register/cursor/index.ts b/config-ui/src/plugins/register/cursor/index.ts
new file mode 100644
index 00000000000..de415db39ab
--- /dev/null
+++ b/config-ui/src/plugins/register/cursor/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+export * from './config';
diff --git a/config-ui/src/plugins/register/index.ts b/config-ui/src/plugins/register/index.ts
index d1fff9c79e2..1d61a93bcaf 100644
--- a/config-ui/src/plugins/register/index.ts
+++ b/config-ui/src/plugins/register/index.ts
@@ -26,6 +26,7 @@ import { BitbucketConfig } from './bitbucket';
import { BitbucketServerConfig } from './bitbucket-server';
import { CircleCIConfig } from './circleci';
import { ClaudeCodeConfig } from './claude-code';
+import { CursorConfig } from './cursor';
import { GitHubConfig } from './github';
import { GhCopilotConfig } from './gh-copilot';
import { GitLabConfig } from './gitlab';
@@ -55,6 +56,7 @@ export const pluginConfigs: IPluginConfig[] = [
BitbucketServerConfig,
CircleCIConfig,
ClaudeCodeConfig,
+ CursorConfig,
GitHubConfig,
GhCopilotConfig,
GitLabConfig,
diff --git a/grafana/dashboards/mysql/ai-cost-efficiency.json b/grafana/dashboards/mysql/ai-cost-efficiency.json
index d52905d0cca..d8eb3fa7cc5 100644
--- a/grafana/dashboards/mysql/ai-cost-efficiency.json
+++ b/grafana/dashboards/mysql/ai-cost-efficiency.json
@@ -27,6 +27,17 @@
"title": "Kiro Usage Dashboard",
"type": "link",
"url": "/d/qdev_user_report"
+ },
+ {
+ "asDropdown": false,
+ "icon": "external link",
+ "includeVars": true,
+ "keepTime": true,
+ "tags": [],
+ "targetBlank": true,
+ "title": "Cursor Usage & Cost",
+ "type": "link",
+ "url": "/d/cursor_usage"
}
],
"panels": [
@@ -301,17 +312,244 @@
],
"title": "Weekly AI Activity Volume",
"type": "timeseries"
+ },
+ {
+ "collapsed": false,
+ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 24 },
+ "id": 30,
+ "panels": [],
+ "title": "Cursor Cost Efficiency",
+ "type": "row"
+ },
+ {
+ "datasource": "mysql",
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "decimals": 2,
+ "unit": "currencyUSD",
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 6, "w": 6, "x": 0, "y": 25 },
+ "id": 31,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "area",
+ "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
+ "textMode": "auto"
+ },
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT SUM(charged_cents) / 100 AS 'Total Spend'\nFROM _tool_cursor_usage_events\nWHERE connection_id = ${cursor_connection_id}\n AND scope_id = '${cursor_scope_id}'\n AND $__timeFilter(event_time)",
+ "refId": "A"
+ }
+ ],
+ "title": "Cursor Total Spend",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "decimals": 2,
+ "unit": "currencyUSD",
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 6, "w": 6, "x": 6, "y": 25 },
+ "id": 32,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "area",
+ "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
+ "textMode": "auto"
+ },
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT ROUND(SUM(e.charged_cents) / 100 / NULLIF(COUNT(DISTINCT pr.id), 0), 2) AS '$ / PR'\nFROM _tool_cursor_usage_events e\nCROSS JOIN (\n SELECT DISTINCT id FROM pull_requests\n WHERE merged_date IS NOT NULL AND $__timeFilter(merged_date)\n) pr\nWHERE e.connection_id = ${cursor_connection_id}\n AND e.scope_id = '${cursor_scope_id}'\n AND $__timeFilter(e.event_time)",
+ "refId": "A"
+ }
+ ],
+ "title": "Cursor $ per PR",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "decimals": 2,
+ "unit": "currencyUSD",
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 6, "w": 6, "x": 12, "y": 25 },
+ "id": 33,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "area",
+ "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
+ "textMode": "auto"
+ },
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT ROUND(SUM(e.charged_cents) / 100 / NULLIF(COUNT(DISTINCT cdc.cicd_deployment_id), 0), 2) AS '$ / Deploy'\nFROM _tool_cursor_usage_events e\nCROSS JOIN (\n SELECT DISTINCT cicd_deployment_id\n FROM cicd_deployment_commits\n WHERE result = 'SUCCESS' AND environment = 'PRODUCTION'\n AND $__timeFilter(finished_date)\n) cdc\nWHERE e.connection_id = ${cursor_connection_id}\n AND e.scope_id = '${cursor_scope_id}'\n AND $__timeFilter(e.event_time)",
+ "refId": "A"
+ }
+ ],
+ "title": "Cursor $ per Deployment",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "decimals": 2,
+ "unit": "currencyUSD",
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 6, "w": 6, "x": 18, "y": 25 },
+ "id": 34,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "area",
+ "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
+ "textMode": "auto"
+ },
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT ROUND(SUM(e.charged_cents) / 100 / NULLIF(COUNT(DISTINCT i.id), 0), 2) AS '$ / Issue'\nFROM _tool_cursor_usage_events e\nCROSS JOIN (\n SELECT DISTINCT id FROM issues\n WHERE resolution_date IS NOT NULL AND type != 'INCIDENT'\n AND $__timeFilter(resolution_date)\n) i\nWHERE e.connection_id = ${cursor_connection_id}\n AND e.scope_id = '${cursor_scope_id}'\n AND $__timeFilter(e.event_time)",
+ "refId": "A"
+ }
+ ],
+ "title": "Cursor $ per Issue Resolved",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Weekly Cursor spend per merged PR",
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "palette-classic" },
+ "custom": {
+ "axisBorderShow": false, "axisLabel": "", "axisPlacement": "auto",
+ "drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "smooth", "lineWidth": 2,
+ "pointSize": 5, "showPoints": "never", "spanNulls": true,
+ "stacking": { "mode": "none" }, "thresholdsStyle": { "mode": "off" }
+ },
+ "unit": "currencyUSD"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 31 },
+ "id": 35,
+ "options": {
+ "legend": { "calcs": ["mean", "min"], "displayMode": "table", "placement": "right", "showLegend": true },
+ "tooltip": { "mode": "multi" }
+ },
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "time_series",
+ "rawQuery": true,
+ "rawSql": "WITH _spend AS (\n SELECT DATE_SUB(DATE(event_time), INTERVAL WEEKDAY(DATE(event_time)) DAY) AS week_start,\n SUM(charged_cents) / 100 AS spend_usd\n FROM _tool_cursor_usage_events\n WHERE connection_id = ${cursor_connection_id}\n AND scope_id = '${cursor_scope_id}'\n AND $__timeFilter(event_time)\n GROUP BY DATE_SUB(DATE(event_time), INTERVAL WEEKDAY(DATE(event_time)) DAY)\n),\n_prs AS (\n SELECT DATE_SUB(DATE(merged_date), INTERVAL WEEKDAY(DATE(merged_date)) DAY) AS week_start,\n COUNT(*) AS prs\n FROM pull_requests\n WHERE merged_date IS NOT NULL AND $__timeFilter(merged_date)\n GROUP BY DATE_SUB(DATE(merged_date), INTERVAL WEEKDAY(DATE(merged_date)) DAY)\n)\nSELECT s.week_start AS time,\n ROUND(s.spend_usd / NULLIF(p.prs, 0), 2) AS '$ per PR'\nFROM _spend s\nLEFT JOIN _prs p ON s.week_start = p.week_start\nORDER BY time",
+ "refId": "A"
+ }
+ ],
+ "title": "Cursor $ per Merged PR (Weekly)",
+ "type": "timeseries"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Weekly Cursor spend per production deployment",
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "palette-classic" },
+ "custom": {
+ "axisBorderShow": false, "axisLabel": "", "axisPlacement": "auto",
+ "drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "smooth", "lineWidth": 2,
+ "pointSize": 5, "showPoints": "never", "spanNulls": true,
+ "stacking": { "mode": "none" }, "thresholdsStyle": { "mode": "off" }
+ },
+ "unit": "currencyUSD"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 31 },
+ "id": 36,
+ "options": {
+ "legend": { "calcs": ["mean", "min"], "displayMode": "table", "placement": "right", "showLegend": true },
+ "tooltip": { "mode": "multi" }
+ },
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "time_series",
+ "rawQuery": true,
+ "rawSql": "WITH _spend AS (\n SELECT DATE_SUB(DATE(event_time), INTERVAL WEEKDAY(DATE(event_time)) DAY) AS week_start,\n SUM(charged_cents) / 100 AS spend_usd\n FROM _tool_cursor_usage_events\n WHERE connection_id = ${cursor_connection_id}\n AND scope_id = '${cursor_scope_id}'\n AND $__timeFilter(event_time)\n GROUP BY DATE_SUB(DATE(event_time), INTERVAL WEEKDAY(DATE(event_time)) DAY)\n),\n_deploys AS (\n SELECT DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY) AS week_start,\n COUNT(DISTINCT cicd_deployment_id) AS deploys\n FROM cicd_deployment_commits\n WHERE result = 'SUCCESS' AND environment = 'PRODUCTION'\n AND $__timeFilter(finished_date)\n GROUP BY DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY)\n)\nSELECT s.week_start AS time,\n ROUND(s.spend_usd / NULLIF(d.deploys, 0), 2) AS '$ per Deploy'\nFROM _spend s\nLEFT JOIN _deploys d ON s.week_start = d.week_start\nORDER BY time",
+ "refId": "A"
+ }
+ ],
+ "title": "Cursor $ per Deployment (Weekly)",
+ "type": "timeseries"
}
],
"preload": false,
"refresh": "5m",
"schemaVersion": 41,
- "tags": ["q_dev", "kiro", "cost", "efficiency"],
- "templating": { "list": [] },
+ "tags": ["q_dev", "kiro", "cursor", "cost", "efficiency"],
+ "templating": {
+ "list": [
+ {
+ "current": {},
+ "datasource": "mysql",
+ "definition": "SELECT DISTINCT connection_id FROM _tool_cursor_scopes ORDER BY connection_id DESC",
+ "hide": 0,
+ "label": "Cursor Connection",
+ "name": "cursor_connection_id",
+ "options": [],
+ "query": "SELECT DISTINCT connection_id FROM _tool_cursor_scopes ORDER BY connection_id DESC",
+ "refresh": 1,
+ "type": "query"
+ },
+ {
+ "current": {},
+ "datasource": "mysql",
+ "definition": "SELECT DISTINCT id FROM _tool_cursor_scopes WHERE connection_id = CAST('${cursor_connection_id}' AS UNSIGNED)",
+ "hide": 0,
+ "label": "Cursor Scope",
+ "name": "cursor_scope_id",
+ "options": [],
+ "query": "SELECT DISTINCT id FROM _tool_cursor_scopes WHERE connection_id = CAST('${cursor_connection_id}' AS UNSIGNED)",
+ "refresh": 1,
+ "type": "query"
+ }
+ ]
+ },
"time": { "from": "now-90d", "to": "now" },
"timepicker": {},
"timezone": "utc",
"title": "AI Cost-Efficiency",
"uid": "ai_cost_efficiency",
- "version": 1
+ "version": 2
}
diff --git a/grafana/dashboards/mysql/cursor-usage.json b/grafana/dashboards/mysql/cursor-usage.json
new file mode 100644
index 00000000000..2331169331e
--- /dev/null
+++ b/grafana/dashboards/mysql/cursor-usage.json
@@ -0,0 +1,1354 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 1,
+ "links": [],
+ "liveNow": false,
+ "panels": [
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "id": 100,
+ "title": "Overview",
+ "type": "row"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Total charged amount for billable usage events in the selected time range",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "currencyUSD",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "text",
+ "value": null
+ }
+ ]
+ },
+ "decimals": 2
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 0,
+ "y": 1
+ },
+ "id": 1,
+ "options": {
+ "colorMode": "none",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto",
+ "wideLayout": true
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT SUM(charged_cents) / 100 AS \"Charged Amount\"\nFROM _tool_cursor_usage_events\nWHERE $__timeFilter(event_time)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'",
+ "refId": "A"
+ }
+ ],
+ "title": "Charged Amount (Selected Period)",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Current billing cycle on-demand spend from /teams/spend",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "currencyUSD",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "text",
+ "value": null
+ }
+ ]
+ },
+ "decimals": 2
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 0,
+ "y": 5
+ },
+ "id": 5,
+ "options": {
+ "colorMode": "none",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto",
+ "wideLayout": true
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT COALESCE(SUM(spend_cents), 0) / 100 AS \"On-Demand Spend\"\nFROM _tool_cursor_user_spend\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'",
+ "refId": "A"
+ }
+ ],
+ "title": "On-Demand Spend (Current Cycle)",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Current billing cycle included (non-on-demand) spend from /teams/spend",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "currencyUSD",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "text",
+ "value": null
+ }
+ ]
+ },
+ "decimals": 2
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 6,
+ "y": 5
+ },
+ "id": 301,
+ "options": {
+ "colorMode": "none",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto",
+ "wideLayout": true
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT COALESCE(SUM(included_spend_cents), 0) / 100 AS \"Included Spend\"\nFROM _tool_cursor_user_spend\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'",
+ "refId": "A"
+ }
+ ],
+ "title": "Included Spend (Current Cycle)",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Difference between total cycle spend (on-demand + included) from /teams/spend and sum of chargedCents for events since billing_cycle_start. Positive means spend exceeds collected events (backfill may be incomplete).",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "currencyUSD",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "text",
+ "value": null
+ }
+ ]
+ },
+ "decimals": 2
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 12,
+ "y": 5
+ },
+ "id": 302,
+ "options": {
+ "colorMode": "none",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto",
+ "wideLayout": true
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT (\n COALESCE((SELECT SUM(spend_cents + included_spend_cents) FROM _tool_cursor_user_spend\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'), 0)\n - COALESCE((SELECT SUM(charged_cents) FROM _tool_cursor_usage_events\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n AND event_time >= (SELECT MIN(billing_cycle_start) FROM _tool_cursor_user_spend\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}')), 0)\n) / 100 AS \"Reconciliation Delta\"",
+ "refId": "A"
+ }
+ ],
+ "title": "Reconciliation Delta",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Total usage events in selected period from filtered-usage-events API",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "locale",
+ "decimals": 0,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "text",
+ "value": null
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 6,
+ "y": 1
+ },
+ "id": 3,
+ "options": {
+ "colorMode": "none",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto",
+ "wideLayout": true
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT COUNT(*) AS \"Usage Events\"\nFROM _tool_cursor_usage_events\nWHERE $__timeFilter(event_time)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'",
+ "refId": "A"
+ }
+ ],
+ "title": "Usage Events (Selected Period)",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Active roster count from /teams/members where is_removed = 0",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "locale",
+ "decimals": 0,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "text",
+ "value": null
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 12,
+ "y": 1
+ },
+ "id": 4,
+ "options": {
+ "colorMode": "none",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto",
+ "wideLayout": true
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT COUNT(*) AS \"Team Members\"\nFROM _tool_cursor_members\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND is_removed = 0",
+ "refId": "A"
+ }
+ ],
+ "title": "Team Members",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Team members with zero active days in the trailing 7-day window (relative to latest collected usage_date)",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "locale",
+ "decimals": 0,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "text",
+ "value": null
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 18,
+ "y": 1
+ },
+ "id": 303,
+ "options": {
+ "colorMode": "none",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto",
+ "wideLayout": true
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT COUNT(*) AS \"Inactive Users\"\nFROM (\n SELECT user_id\n FROM _tool_cursor_daily_usage\n WHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND usage_date >= DATE_SUB(\n (SELECT MAX(usage_date) FROM _tool_cursor_daily_usage\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'),\n INTERVAL 6 DAY\n )\n GROUP BY user_id\n HAVING MAX(is_active) = 0\n) t",
+ "refId": "A"
+ }
+ ],
+ "title": "Inactive Users (Last 7 Days)",
+ "type": "stat"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Days since most recent usage event; green < 2d, yellow 2-6d, red >= 7d",
+ "fieldConfig": {
+ "defaults": {
+ "decimals": 0,
+ "unit": "locale",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "yellow",
+ "value": 2
+ },
+ {
+ "color": "red",
+ "value": 7
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 18,
+ "y": 5
+ },
+ "id": 6,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto",
+ "wideLayout": true
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT COALESCE(TIMESTAMPDIFF(DAY, MAX(event_time), UTC_TIMESTAMP()), 9999) AS \"Days Since Latest Event\"\nFROM _tool_cursor_usage_events\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'",
+ "refId": "A"
+ }
+ ],
+ "title": "Data Freshness",
+ "type": "stat"
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 9
+ },
+ "id": 101,
+ "title": "Cost Trends",
+ "type": "row"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Daily charged spend split by billing kind: on-demand (Usage-based) vs included (Included in Business)",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "axisLabel": "USD",
+ "drawStyle": "bars",
+ "fillOpacity": 80,
+ "lineWidth": 1,
+ "showPoints": "never",
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ }
+ },
+ "decimals": 2,
+ "unit": "currencyUSD"
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "On-Demand"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "blue",
+ "mode": "fixed"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Included"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "yellow",
+ "mode": "fixed"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 10
+ },
+ "id": 10,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT DATE(event_time) AS time,\n SUM(CASE WHEN kind = 'Usage-based' THEN charged_cents ELSE 0 END) / 100 AS \"On-Demand\",\n SUM(CASE WHEN kind = 'Included in Business' THEN charged_cents ELSE 0 END) / 100 AS \"Included\"\nFROM _tool_cursor_usage_events\nWHERE $__timeFilter(event_time)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nGROUP BY DATE(event_time)\nORDER BY 1",
+ "refId": "A"
+ }
+ ],
+ "title": "Daily Spend (On-Demand + Included)",
+ "type": "timeseries"
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 18
+ },
+ "id": 102,
+ "title": "Breakdowns",
+ "type": "row"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Average cost per event, total charged, event count, and share of total events by model for the selected period. Table defaults to charged spend descending.",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "align": "auto",
+ "cellOptions": {
+ "type": "auto"
+ },
+ "inspect": false
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Model"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 320
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Avg Cost"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 120
+ },
+ {
+ "id": "custom.align",
+ "value": "right"
+ },
+ {
+ "id": "decimals",
+ "value": 4
+ },
+ {
+ "id": "unit",
+ "value": "currencyUSD"
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Charged"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 140
+ },
+ {
+ "id": "custom.align",
+ "value": "right"
+ },
+ {
+ "id": "decimals",
+ "value": 2
+ },
+ {
+ "id": "unit",
+ "value": "currencyUSD"
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Events"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "locale"
+ },
+ {
+ "id": "decimals",
+ "value": 0
+ },
+ {
+ "id": "custom.width",
+ "value": 100
+ },
+ {
+ "id": "custom.align",
+ "value": "right"
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Usage %"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "percent"
+ },
+ {
+ "id": "decimals",
+ "value": 1
+ },
+ {
+ "id": "custom.width",
+ "value": 80
+ },
+ {
+ "id": "custom.align",
+ "value": "right"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 14,
+ "x": 0,
+ "y": 19
+ },
+ "id": 21,
+ "options": {
+ "cellHeight": "sm",
+ "footer": {
+ "show": false
+ },
+ "showHeader": true
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT model AS \"Model\",\n SUM(total_cents) / COUNT(*) / 100 AS \"Avg Cost\",\n SUM(charged_cents) / 100 AS \"Charged\",\n COUNT(*) AS \"Events\",\n COUNT(*) * 100.0 / SUM(COUNT(*)) OVER () AS \"Usage %\"\nFROM _tool_cursor_usage_events\nWHERE $__timeFilter(event_time)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nGROUP BY model\nORDER BY SUM(charged_cents) DESC",
+ "refId": "A"
+ }
+ ],
+ "title": "Cost by Model",
+ "type": "table"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Total charged spend by billing kind for the selected period",
+ "fieldConfig": {
+ "defaults": {
+ "unit": "currencyUSD",
+ "decimals": 2,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "blue",
+ "value": null
+ }
+ ]
+ },
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "blue"
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Usage-based"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "blue",
+ "mode": "fixed"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Included in Business"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "yellow",
+ "mode": "fixed"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Free"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "orange",
+ "mode": "fixed"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Errored, Not Charged"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "red",
+ "mode": "fixed"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Aborted, Not Charged"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "purple",
+ "mode": "fixed"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "User API Key"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "text",
+ "mode": "fixed"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 10,
+ "x": 14,
+ "y": 19
+ },
+ "id": 22,
+ "options": {
+ "orientation": "horizontal",
+ "displayMode": "basic",
+ "showUnfilled": false,
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": true
+ },
+ "text": {}
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT kind AS \"Kind\",\n SUM(charged_cents) / 100 AS \"Charged\"\nFROM _tool_cursor_usage_events\nWHERE $__timeFilter(event_time)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nGROUP BY kind\nORDER BY SUM(charged_cents) DESC",
+ "refId": "A"
+ }
+ ],
+ "title": "Cost by Kind",
+ "type": "bargauge",
+ "transformations": [
+ {
+ "id": "rowsToFields",
+ "options": {
+ "mappings": [
+ {
+ "fieldName": "Kind",
+ "handlerKey": "field.name"
+ },
+ {
+ "fieldName": "Charged",
+ "handlerKey": "field.value"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 27
+ },
+ "id": 200,
+ "title": "Adoption (Daily Usage)",
+ "type": "row"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Daily active users based on isActive flag from /teams/daily-usage-data",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "fixedColor": "blue",
+ "mode": "fixed"
+ },
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "lineWidth": 2,
+ "showPoints": "auto",
+ "pointSize": 4
+ },
+ "unit": "locale",
+ "decimals": 0
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 0,
+ "y": 28
+ },
+ "id": 201,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "single",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT usage_date AS time,\n COUNT(DISTINCT user_id) AS \"Active Users\"\nFROM _tool_cursor_daily_usage\nWHERE $__timeFilter(usage_date)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND is_active = 1\nGROUP BY usage_date\nORDER BY 1",
+ "refId": "A"
+ }
+ ],
+ "title": "Daily Active Users (DAU)",
+ "type": "timeseries"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Tab inline-completion acceptance rate only (tabs_accepted / tabs_shown); see AI Line Acceptance Rate for all accepted lines",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "fixedColor": "blue",
+ "mode": "fixed"
+ },
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "lineWidth": 2,
+ "showPoints": "auto",
+ "pointSize": 4
+ },
+ "unit": "percent",
+ "decimals": 1
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 8,
+ "y": 28
+ },
+ "id": 202,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "single",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT usage_date AS time,\n SUM(tabs_accepted) * 100.0 / NULLIF(SUM(tabs_shown), 0) AS \"Acceptance Rate\"\nFROM _tool_cursor_daily_usage\nWHERE $__timeFilter(usage_date)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND is_active = 1\nGROUP BY usage_date\nORDER BY 1",
+ "refId": "A"
+ }
+ ],
+ "title": "Tab Acceptance Rate",
+ "type": "timeseries"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Daily usage-event volume by channel from POST /teams/filtered-usage-events. Admin API does not populate hostingType in collected data (Apr–Jul 2026: 0/105k raw events). Mapping: is_headless=1 → Cloud Agent (~1%); empty hosting_type + not headless → IDE; hosting_type=cli → CLI when present. CLI and authoritative IDE/CLI/Cloud commit activity on the Cursor native dashboard use Enterprise GET /analytics/ai-code/commits (commitSource) — not collected.",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "drawStyle": "bars",
+ "fillOpacity": 80,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ }
+ },
+ "unit": "locale",
+ "decimals": 0
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "IDE"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "green",
+ "mode": "fixed"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "CLI"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "light-blue",
+ "mode": "fixed"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Cloud Agent"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "blue",
+ "mode": "fixed"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Other"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "text",
+ "mode": "fixed"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 16,
+ "y": 28
+ },
+ "id": 203,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT DATE(event_time) AS time,\n CASE\n WHEN is_headless = 1 THEN 'Cloud Agent'\n WHEN LOWER(TRIM(COALESCE(hosting_type, ''))) IN ('cli', 'cursor-cli') THEN 'CLI'\n WHEN LOWER(TRIM(COALESCE(hosting_type, ''))) IN ('cloud', 'cloud_agent', 'cloud-agent') THEN 'Cloud Agent'\n WHEN TRIM(COALESCE(hosting_type, '')) = '' THEN 'IDE'\n ELSE 'Other'\n END AS metric,\n COUNT(*) AS value\nFROM _tool_cursor_usage_events\nWHERE $__timeFilter(event_time)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nGROUP BY 1, 2\nORDER BY 1",
+ "refId": "A"
+ }
+ ],
+ "title": "Activity by Channel",
+ "type": "timeseries",
+ "transformations": [
+ {
+ "id": "prepareTimeSeries",
+ "options": {
+ "format": "many"
+ }
+ },
+ {
+ "id": "renameByRegex",
+ "options": {
+ "regex": "^value (.+)$",
+ "renamePattern": "$1"
+ }
+ }
+ ]
+ },
+ {
+ "datasource": "mysql",
+ "description": "Daily sum of AI-suggested lines accepted into the editor (acceptedLinesAdded from /teams/daily-usage-data)",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "fixedColor": "blue",
+ "mode": "fixed"
+ },
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 20,
+ "lineWidth": 2,
+ "showPoints": "auto",
+ "pointSize": 4
+ },
+ "unit": "locale",
+ "decimals": 0
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 36
+ },
+ "id": 204,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "single",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT usage_date AS time,\n SUM(accepted_lines_added) AS \"Accepted Lines Added\"\nFROM _tool_cursor_daily_usage\nWHERE $__timeFilter(usage_date)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND is_active = 1\nGROUP BY usage_date\nORDER BY 1",
+ "refId": "A"
+ }
+ ],
+ "title": "Accepted Lines Added",
+ "type": "timeseries"
+ },
+ {
+ "datasource": "mysql",
+ "description": "Share of all lines added that came from accepted AI suggestions (accepted_lines_added / total_lines_added). Editor activity only \u2014 not committed-code AI share.",
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "fixedColor": "blue",
+ "mode": "fixed"
+ },
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "lineWidth": 2,
+ "showPoints": "auto",
+ "pointSize": 4
+ },
+ "unit": "percent",
+ "decimals": 1
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 36
+ },
+ "id": 205,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "single",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT usage_date AS time,\n SUM(accepted_lines_added) * 100.0 / NULLIF(SUM(total_lines_added), 0) AS \"AI Line Acceptance Rate\"\nFROM _tool_cursor_daily_usage\nWHERE $__timeFilter(usage_date)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND is_active = 1\nGROUP BY usage_date\nORDER BY 1",
+ "refId": "A"
+ }
+ ],
+ "title": "AI Line Acceptance Rate",
+ "type": "timeseries"
+ },
+ {
+ "collapsed": true,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 44
+ },
+ "id": 104,
+ "title": "Diagnostics",
+ "type": "row",
+ "panels": [
+ {
+ "datasource": "mysql",
+ "description": "Row counts and latest timestamps per _tool_cursor_* table for pipeline health monitoring",
+ "fieldConfig": {
+ "defaults": {},
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 12,
+ "x": 0,
+ "y": 45
+ },
+ "id": 40,
+ "options": {
+ "cellHeight": "sm",
+ "footer": {
+ "show": false
+ },
+ "showHeader": true
+ },
+ "pluginVersion": "11.0.0",
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "table",
+ "rawQuery": true,
+ "rawSql": "SELECT '_tool_cursor_usage_events' AS table_name, COUNT(*) AS row_count, MAX(event_time) AS latest\nFROM _tool_cursor_usage_events\nWHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\nUNION ALL\nSELECT '_tool_cursor_user_spend', COUNT(*), MAX(collected_at)\nFROM _tool_cursor_user_spend\nWHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\nUNION ALL\nSELECT '_tool_cursor_members', COUNT(*), NULL\nFROM _tool_cursor_members\nWHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\nUNION ALL\nSELECT '_tool_cursor_daily_usage', COUNT(*), MAX(usage_date)\nFROM _tool_cursor_daily_usage\nWHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'",
+ "refId": "A"
+ }
+ ],
+ "title": "Data Volume",
+ "type": "table"
+ }
+ ]
+ }
+ ],
+ "refresh": "",
+ "schemaVersion": 38,
+ "tags": [
+ "cursor",
+ "devlake",
+ "ai"
+ ],
+ "templating": {
+ "list": [
+ {
+ "current": {
+ "selected": false,
+ "text": "1",
+ "value": "1"
+ },
+ "datasource": "mysql",
+ "definition": "SELECT DISTINCT connection_id FROM _tool_cursor_scopes ORDER BY connection_id",
+ "hide": 0,
+ "includeAll": false,
+ "label": "Connection ID",
+ "name": "connection_id",
+ "options": [],
+ "query": "SELECT DISTINCT connection_id FROM _tool_cursor_scopes ORDER BY connection_id",
+ "refresh": 1,
+ "regex": "",
+ "sort": 0,
+ "type": "query"
+ },
+ {
+ "current": {
+ "selected": false,
+ "text": "team",
+ "value": "team"
+ },
+ "datasource": "mysql",
+ "definition": "SELECT id AS scope_id FROM _tool_cursor_scopes WHERE connection_id = ${connection_id} ORDER BY id",
+ "hide": 0,
+ "includeAll": false,
+ "label": "Scope ID",
+ "name": "scope_id",
+ "options": [],
+ "query": "SELECT id AS scope_id FROM _tool_cursor_scopes WHERE connection_id = ${connection_id} ORDER BY id",
+ "refresh": 2,
+ "regex": "",
+ "sort": 0,
+ "type": "query"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-90d",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "utc",
+ "title": "Cursor Usage & Cost",
+ "uid": "cursor_usage",
+ "version": 14,
+ "weekStart": ""
+}
diff --git a/grafana/dashboards/mysql/multi-ai-comparison.json b/grafana/dashboards/mysql/multi-ai-comparison.json
index 617cda77637..30d8b5dc4de 100644
--- a/grafana/dashboards/mysql/multi-ai-comparison.json
+++ b/grafana/dashboards/mysql/multi-ai-comparison.json
@@ -38,6 +38,17 @@
"title": "Copilot Adoption",
"type": "link",
"url": "/d/copilot_adoption"
+ },
+ {
+ "asDropdown": false,
+ "icon": "external link",
+ "includeVars": true,
+ "keepTime": true,
+ "tags": [],
+ "targetBlank": true,
+ "title": "Cursor Usage",
+ "type": "link",
+ "url": "/d/cursor_usage"
}
],
"panels": [
@@ -50,7 +61,7 @@
"id": 1,
"options": {
"code": { "language": "plaintext", "showLineNumbers": false, "showMiniMap": false },
- "content": "## Multi-AI Tool Comparison: Copilot vs Kiro\nCompare GitHub Copilot and Kiro (Amazon Q Developer) adoption and usage side by side.\n\n**Note:** Copilot metrics come from `_tool_copilot_enterprise_daily_metrics` (enterprise-level aggregates). Kiro metrics come from `_tool_q_dev_user_report` and `_tool_q_dev_user_data`. Select the Copilot connection and scope using the dropdowns above.",
+ "content": "## Multi-AI Tool Comparison: Copilot vs Kiro vs Cursor\nCompare GitHub Copilot, Kiro (Amazon Q Developer), and Cursor adoption and usage side by side.\n\n**Note:** Copilot metrics come from `_tool_copilot_enterprise_daily_metrics`. Kiro metrics come from `_tool_q_dev_user_report` and `_tool_q_dev_user_data`. Cursor metrics come from `_tool_cursor_daily_usage`. Select connections and scopes using the dropdowns above.",
"mode": "markdown"
},
"title": "About",
@@ -100,6 +111,13 @@
"rawQuery": true,
"rawSql": "SELECT\n DATE_SUB(DATE(day), INTERVAL WEEKDAY(DATE(day)) DAY) AS time,\n MAX(daily_active_users) AS 'Copilot Active Users'\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE connection_id = ${copilot_connection_id}\n AND scope_id = '${copilot_scope_id}'\n AND $__timeFilter(day)\nGROUP BY DATE_SUB(DATE(day), INTERVAL WEEKDAY(DATE(day)) DAY)\nORDER BY time",
"refId": "B"
+ },
+ {
+ "datasource": "mysql",
+ "format": "time_series",
+ "rawQuery": true,
+ "rawSql": "SELECT\n DATE_SUB(DATE(usage_date), INTERVAL WEEKDAY(DATE(usage_date)) DAY) AS time,\n COUNT(DISTINCT user_id) AS 'Cursor Active Users'\nFROM _tool_cursor_daily_usage\nWHERE connection_id = ${cursor_connection_id}\n AND scope_id = '${cursor_scope_id}'\n AND is_active = 1\n AND $__timeFilter(usage_date)\nGROUP BY DATE_SUB(DATE(usage_date), INTERVAL WEEKDAY(DATE(usage_date)) DAY)\nORDER BY time",
+ "refId": "C"
}
],
"title": "Weekly Active Users Comparison",
@@ -181,9 +199,43 @@
"title": "Copilot: Code Suggestions & Acceptance",
"type": "timeseries"
},
+ {
+ "datasource": "mysql",
+ "description": "Weekly Cursor tab completions: shown vs accepted",
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "palette-classic" },
+ "custom": {
+ "axisBorderShow": false, "axisLabel": "", "axisPlacement": "auto",
+ "drawStyle": "line", "fillOpacity": 10, "lineInterpolation": "smooth", "lineWidth": 2,
+ "pointSize": 5, "showPoints": "never", "spanNulls": true,
+ "stacking": { "mode": "none" }, "thresholdsStyle": { "mode": "off" }
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 21 },
+ "id": 13,
+ "options": {
+ "legend": { "calcs": ["mean", "sum"], "displayMode": "table", "placement": "right", "showLegend": true },
+ "tooltip": { "mode": "multi" }
+ },
+ "targets": [
+ {
+ "datasource": "mysql",
+ "format": "time_series",
+ "rawQuery": true,
+ "rawSql": "SELECT\n DATE_SUB(DATE(usage_date), INTERVAL WEEKDAY(DATE(usage_date)) DAY) AS time,\n SUM(tabs_shown) AS 'Cursor: Suggestions',\n SUM(tabs_accepted) AS 'Cursor: Accepted'\nFROM _tool_cursor_daily_usage\nWHERE connection_id = ${cursor_connection_id}\n AND scope_id = '${cursor_scope_id}'\n AND $__timeFilter(usage_date)\nGROUP BY DATE_SUB(DATE(usage_date), INTERVAL WEEKDAY(DATE(usage_date)) DAY)\nORDER BY time",
+ "refId": "A"
+ }
+ ],
+ "title": "Cursor: Tab Completions",
+ "type": "timeseries"
+ },
{
"collapsed": false,
- "gridPos": { "h": 1, "w": 24, "x": 0, "y": 21 },
+ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 29 },
"id": 20,
"panels": [],
"title": "Lines of Code & Acceptance Rate",
@@ -216,7 +268,7 @@
"datasource": {"type": "mysql", "uid": "devlake-mysql-api"},
"format": "time_series",
"rawQuery": true,
- "rawSql": "SELECT time, SUM(kiro_loc) AS 'Kiro LOC Accepted', SUM(copilot_loc) AS 'Copilot LOC Added'\nFROM (\n SELECT DATE_SUB(DATE(date), INTERVAL WEEKDAY(DATE(date)) DAY) AS time,\n SUM(inline_ai_code_lines + chat_ai_code_lines) AS kiro_loc, 0 AS copilot_loc\n FROM _tool_q_dev_user_data WHERE $__timeFilter(date)\n GROUP BY DATE_SUB(DATE(date), INTERVAL WEEKDAY(DATE(date)) DAY)\n UNION ALL\n SELECT DATE_SUB(DATE(day), INTERVAL WEEKDAY(DATE(day)) DAY) AS time,\n 0 AS kiro_loc, SUM(loc_added_sum) AS copilot_loc\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${copilot_connection_id}\n AND scope_id = '${copilot_scope_id}'\n AND $__timeFilter(day)\n GROUP BY DATE_SUB(DATE(day), INTERVAL WEEKDAY(DATE(day)) DAY)\n) combined\nGROUP BY time ORDER BY time",
+ "rawSql": "SELECT time, SUM(kiro_loc) AS 'Kiro LOC Accepted', SUM(copilot_loc) AS 'Copilot LOC Added', SUM(cursor_loc) AS 'Cursor LOC Added'\nFROM (\n SELECT DATE_SUB(DATE(date), INTERVAL WEEKDAY(DATE(date)) DAY) AS time,\n SUM(inline_ai_code_lines + chat_ai_code_lines) AS kiro_loc, 0 AS copilot_loc, 0 AS cursor_loc\n FROM _tool_q_dev_user_data WHERE $__timeFilter(date)\n GROUP BY DATE_SUB(DATE(date), INTERVAL WEEKDAY(DATE(date)) DAY)\n UNION ALL\n SELECT DATE_SUB(DATE(day), INTERVAL WEEKDAY(DATE(day)) DAY) AS time,\n 0 AS kiro_loc, SUM(loc_added_sum) AS copilot_loc, 0 AS cursor_loc\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${copilot_connection_id}\n AND scope_id = '${copilot_scope_id}'\n AND $__timeFilter(day)\n GROUP BY DATE_SUB(DATE(day), INTERVAL WEEKDAY(DATE(day)) DAY)\n UNION ALL\n SELECT DATE_SUB(DATE(usage_date), INTERVAL WEEKDAY(DATE(usage_date)) DAY) AS time,\n 0 AS kiro_loc, 0 AS copilot_loc, SUM(lines_added) AS cursor_loc\n FROM _tool_cursor_daily_usage\n WHERE connection_id = ${cursor_connection_id}\n AND scope_id = '${cursor_scope_id}'\n AND $__timeFilter(usage_date)\n GROUP BY DATE_SUB(DATE(usage_date), INTERVAL WEEKDAY(DATE(usage_date)) DAY)\n) combined\nGROUP BY time ORDER BY time",
"refId": "A"
}
],
@@ -250,7 +302,7 @@
"datasource": {"type": "mysql", "uid": "devlake-mysql-api"},
"format": "table",
"rawQuery": true,
- "rawSql": "SELECT 'Kiro' AS Tool,\n ROUND(SUM(inline_acceptance_count) * 100.0 / NULLIF(SUM(inline_suggestions_count), 0), 1) AS 'Acceptance Rate'\nFROM _tool_q_dev_user_data\nWHERE $__timeFilter(date)\nUNION ALL\nSELECT 'Copilot' AS Tool,\n ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1)\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE connection_id = ${copilot_connection_id}\n AND scope_id = '${copilot_scope_id}'\n AND $__timeFilter(day)",
+ "rawSql": "SELECT 'Kiro' AS Tool,\n ROUND(SUM(inline_acceptance_count) * 100.0 / NULLIF(SUM(inline_suggestions_count), 0), 1) AS 'Acceptance Rate'\nFROM _tool_q_dev_user_data\nWHERE $__timeFilter(date)\nUNION ALL\nSELECT 'Copilot' AS Tool,\n ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1)\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE connection_id = ${copilot_connection_id}\n AND scope_id = '${copilot_scope_id}'\n AND $__timeFilter(day)\nUNION ALL\nSELECT 'Cursor' AS Tool,\n ROUND(SUM(tabs_accepted) * 100.0 / NULLIF(SUM(tabs_shown), 0), 1)\nFROM _tool_cursor_daily_usage\nWHERE connection_id = ${cursor_connection_id}\n AND scope_id = '${cursor_scope_id}'\n AND $__timeFilter(usage_date)",
"refId": "A"
}
],
@@ -261,7 +313,7 @@
"preload": false,
"refresh": "5m",
"schemaVersion": 41,
- "tags": ["q_dev", "copilot", "kiro", "comparison"],
+ "tags": ["q_dev", "copilot", "kiro", "cursor", "comparison"],
"templating": {
"list": [
{
@@ -287,13 +339,37 @@
"query": "SELECT DISTINCT id FROM _tool_copilot_scopes WHERE connection_id = CAST('${copilot_connection_id}' AS UNSIGNED)",
"refresh": 1,
"type": "query"
+ },
+ {
+ "current": {},
+ "datasource": "mysql",
+ "definition": "SELECT DISTINCT connection_id FROM _tool_cursor_scopes ORDER BY connection_id DESC",
+ "hide": 0,
+ "label": "Cursor Connection",
+ "name": "cursor_connection_id",
+ "options": [],
+ "query": "SELECT DISTINCT connection_id FROM _tool_cursor_scopes ORDER BY connection_id DESC",
+ "refresh": 1,
+ "type": "query"
+ },
+ {
+ "current": {},
+ "datasource": "mysql",
+ "definition": "SELECT DISTINCT id FROM _tool_cursor_scopes WHERE connection_id = CAST('${cursor_connection_id}' AS UNSIGNED)",
+ "hide": 0,
+ "label": "Cursor Scope",
+ "name": "cursor_scope_id",
+ "options": [],
+ "query": "SELECT DISTINCT id FROM _tool_cursor_scopes WHERE connection_id = CAST('${cursor_connection_id}' AS UNSIGNED)",
+ "refresh": 1,
+ "type": "query"
}
]
},
"time": { "from": "now-90d", "to": "now" },
"timepicker": {},
"timezone": "utc",
- "title": "Multi-AI Tool Comparison",
+ "title": "Multi-AI Tool Comparison: Copilot vs Kiro vs Cursor",
"uid": "multi_ai_comparison",
- "version": 1
+ "version": 2
}