This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 7a51debd6 feat:allow claude organization support (#8978)
7a51debd6 is described below
commit 7a51debd6fb2184160fadbae7fcd8e7eafa9bdf6
Author: Salim Shaikh <[email protected]>
AuthorDate: Thu Jul 9 10:22:26 2026 +0300
feat:allow claude organization support (#8978)
---
backend/plugins/claude_code/models/connection.go | 8 +
.../tasks/activity_summary_collector.go | 5 +
.../claude_code/tasks/connector_usage_collector.go | 5 +
.../claude_code/tasks/user_activity_collector.go | 23 ++-
.../claude_code/tasks/user_activity_extractor.go | 195 +++++++++++++++------
5 files changed, 181 insertions(+), 55 deletions(-)
diff --git a/backend/plugins/claude_code/models/connection.go
b/backend/plugins/claude_code/models/connection.go
index 973b710af..700b611ae 100644
--- a/backend/plugins/claude_code/models/connection.go
+++ b/backend/plugins/claude_code/models/connection.go
@@ -62,6 +62,14 @@ func (conn *ClaudeCodeConn) HasUsableCustomHeaders() bool {
return false
}
+// IsConsoleApiKey returns true when the token is a Claude Console Admin API
key.
+// Console keys (sk-ant-admin01-...) have full Admin API access and use
+// /v1/organizations/usage_report/claude_code instead of the Enterprise
+// /v1/organizations/analytics/* endpoints.
+func (conn *ClaudeCodeConn) IsConsoleApiKey() bool {
+ return strings.HasPrefix(strings.TrimSpace(conn.Token),
"sk-ant-admin01-")
+}
+
func (conn *ClaudeCodeConn) HasIncompleteCustomHeaders() bool {
if conn == nil {
return false
diff --git a/backend/plugins/claude_code/tasks/activity_summary_collector.go
b/backend/plugins/claude_code/tasks/activity_summary_collector.go
index 110c44e54..79728f190 100644
--- a/backend/plugins/claude_code/tasks/activity_summary_collector.go
+++ b/backend/plugins/claude_code/tasks/activity_summary_collector.go
@@ -44,6 +44,11 @@ func CollectActivitySummary(taskCtx plugin.SubTaskContext)
errors.Error {
return nil
}
+ if connection.IsConsoleApiKey() {
+ taskCtx.GetLogger().Info("Console API key detected, skipping
activity summary collection (no equivalent endpoint)")
+ return nil
+ }
+
apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection)
if err != nil {
return err
diff --git a/backend/plugins/claude_code/tasks/connector_usage_collector.go
b/backend/plugins/claude_code/tasks/connector_usage_collector.go
index 076cf00e1..2b885b1ef 100644
--- a/backend/plugins/claude_code/tasks/connector_usage_collector.go
+++ b/backend/plugins/claude_code/tasks/connector_usage_collector.go
@@ -44,6 +44,11 @@ func CollectConnectorUsage(taskCtx plugin.SubTaskContext)
errors.Error {
return nil
}
+ if connection.IsConsoleApiKey() {
+ taskCtx.GetLogger().Info("Console API key detected, skipping
connector usage collection (no equivalent endpoint)")
+ return nil
+ }
+
apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection)
if err != nil {
return err
diff --git a/backend/plugins/claude_code/tasks/user_activity_collector.go
b/backend/plugins/claude_code/tasks/user_activity_collector.go
index 16a764721..7239390be 100644
--- a/backend/plugins/claude_code/tasks/user_activity_collector.go
+++ b/backend/plugins/claude_code/tasks/user_activity_collector.go
@@ -30,7 +30,10 @@ import (
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
)
-// CollectUserActivity collects per-user daily engagement metrics from
/v1/organizations/analytics/users.
+// CollectUserActivity collects per-user daily engagement metrics.
+// For Claude Enterprise organizations it calls
/v1/organizations/analytics/users.
+// For Claude Console organizations (sk-ant-admin01-... keys) it calls
+// /v1/organizations/usage_report/claude_code.
func CollectUserActivity(taskCtx plugin.SubTaskContext) errors.Error {
data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData)
if !ok {
@@ -49,6 +52,14 @@ func CollectUserActivity(taskCtx plugin.SubTaskContext)
errors.Error {
return err
}
+ isConsole := connection.IsConsoleApiKey()
+ endpoint := "analytics/users"
+ urlTemplate := "v1/organizations/analytics/users"
+ if isConsole {
+ endpoint = "usage_report/claude_code"
+ urlTemplate = "v1/organizations/usage_report/claude_code"
+ }
+
rawArgs := helper.RawDataSubTaskArgs{
Ctx: taskCtx,
Table: rawUserActivityTable,
@@ -56,7 +67,7 @@ func CollectUserActivity(taskCtx plugin.SubTaskContext)
errors.Error {
ConnectionId: data.Options.ConnectionId,
ScopeId: data.Options.ScopeId,
Organization: connection.Organization,
- Endpoint: "analytics/users",
+ Endpoint: endpoint,
},
}
@@ -73,12 +84,16 @@ func CollectUserActivity(taskCtx plugin.SubTaskContext)
errors.Error {
Input: dayIter,
PageSize: 1,
Incremental: true,
- UrlTemplate: "v1/organizations/analytics/users",
+ UrlTemplate: urlTemplate,
GetNextPageCustomData: getNextClaudeCodePageCursor,
Query: func(reqData *helper.RequestData) (url.Values,
errors.Error) {
input := reqData.Input.(*claudeCodeDayInput)
query := url.Values{}
- query.Set("date", input.Day)
+ if isConsole {
+ query.Set("starting_at", input.Day)
+ } else {
+ query.Set("date", input.Day)
+ }
query.Set("limit", fmt.Sprintf("%d",
claudeCodeApiPageLimit))
if cursor, ok := reqData.CustomData.(string); ok &&
strings.TrimSpace(cursor) != "" {
query.Set("page", cursor)
diff --git a/backend/plugins/claude_code/tasks/user_activity_extractor.go
b/backend/plugins/claude_code/tasks/user_activity_extractor.go
index 040b5a8d4..4050a7c2b 100644
--- a/backend/plugins/claude_code/tasks/user_activity_extractor.go
+++ b/backend/plugins/claude_code/tasks/user_activity_extractor.go
@@ -28,6 +28,8 @@ import (
"github.com/apache/incubator-devlake/plugins/claude_code/models"
)
+// ── Enterprise API response types (/v1/organizations/analytics/users)
──────────
+
// userActivityRecord is the JSON shape returned by
/v1/organizations/analytics/users.
type userActivityRecord struct {
User userActivityUser `json:"user"`
@@ -82,6 +84,48 @@ type userActivityCCTools struct {
NotebookEditTool userActivityToolAction `json:"notebook_edit_tool"`
}
+// ── Console API response types (/v1/organizations/usage_report/claude_code)
───
+
+// consoleUserActivityRecord is the JSON shape returned by
/v1/organizations/usage_report/claude_code.
+type consoleUserActivityRecord struct {
+ Date string `json:"date"`
+ Actor consoleActor `json:"actor"`
+ CoreMetrics consoleCoreMetrics `json:"core_metrics"`
+ ToolActions consoleToolActions `json:"tool_actions"`
+}
+
+type consoleActor struct {
+ Type string `json:"type"`
+ EmailAddress string `json:"email_address"`
+ ApiKeyName string `json:"api_key_name"`
+}
+
+type consoleCoreMetrics struct {
+ NumSessions int `json:"num_sessions"`
+ LinesOfCode consoleLinesOfCode `json:"lines_of_code"`
+ CommitsByClaudeCode int
`json:"commits_by_claude_code"`
+ PullRequestsByClaudeCode int
`json:"pull_requests_by_claude_code"`
+}
+
+type consoleLinesOfCode struct {
+ Added int `json:"added"`
+ Removed int `json:"removed"`
+}
+
+type consoleToolAction struct {
+ Accepted int `json:"accepted"`
+ Rejected int `json:"rejected"`
+}
+
+type consoleToolActions struct {
+ EditTool consoleToolAction `json:"edit_tool"`
+ MultiEditTool consoleToolAction `json:"multi_edit_tool"`
+ WriteTool consoleToolAction `json:"write_tool"`
+ NotebookEditTool consoleToolAction `json:"notebook_edit_tool"`
+}
+
+// ── Extractor
─────────────────────────────────────────────────────────────────
+
// ExtractUserActivity parses raw user activity records into tool-layer tables.
func ExtractUserActivity(taskCtx plugin.SubTaskContext) errors.Error {
data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData)
@@ -96,6 +140,12 @@ func ExtractUserActivity(taskCtx plugin.SubTaskContext)
errors.Error {
return nil
}
+ isConsole := connection.IsConsoleApiKey()
+ endpoint := "analytics/users"
+ if isConsole {
+ endpoint = "usage_report/claude_code"
+ }
+
extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
Ctx: taskCtx,
@@ -104,63 +154,18 @@ func ExtractUserActivity(taskCtx plugin.SubTaskContext)
errors.Error {
ConnectionId: data.Options.ConnectionId,
ScopeId: data.Options.ScopeId,
Organization: connection.Organization,
- Endpoint: "analytics/users",
+ Endpoint: endpoint,
},
},
Extract: func(row *helper.RawData) ([]interface{},
errors.Error) {
- var record userActivityRecord
- if err := errors.Convert(json.Unmarshal(row.Data,
&record)); err != nil {
- return nil, err
- }
-
date, parseErr := parseAnalyticsDate(row.Input)
if parseErr != nil {
return nil, parseErr
}
-
- userId := strings.TrimSpace(record.User.Id)
- if userId == "" {
- userId =
strings.TrimSpace(record.User.EmailAddress)
- }
- if userId == "" {
- return nil, nil
- }
-
- activity := &models.ClaudeCodeUserActivity{
- ConnectionId: data.Options.ConnectionId,
- ScopeId: data.Options.ScopeId,
- Date: date,
- UserId: userId,
- UserEmail:
strings.TrimSpace(record.User.EmailAddress),
-
- ChatConversationCount:
record.ChatMetrics.DistinctConversationCount,
- ChatMessageCount:
record.ChatMetrics.MessageCount,
- ChatProjectsCreatedCount:
record.ChatMetrics.DistinctProjectsCreatedCount,
- ChatProjectsUsedCount:
record.ChatMetrics.DistinctProjectsUsedCount,
- ChatFilesUploadedCount:
record.ChatMetrics.DistinctFilesUploadedCount,
- ChatArtifactsCreatedCount:
record.ChatMetrics.DistinctArtifactsCreatedCount,
- ChatThinkingMessageCount:
record.ChatMetrics.ThinkingMessageCount,
- ChatSkillsUsedCount:
record.ChatMetrics.DistinctSkillsUsedCount,
- ChatConnectorsUsedCount:
record.ChatMetrics.ConnectorsUsedCount,
-
- CCCommitCount:
record.ClaudeCodeMetrics.CoreMetrics.CommitCount,
- CCPullRequestCount:
record.ClaudeCodeMetrics.CoreMetrics.PullRequestCount,
- CCLinesAdded:
record.ClaudeCodeMetrics.CoreMetrics.LinesOfCode.AddedCount,
- CCLinesRemoved:
record.ClaudeCodeMetrics.CoreMetrics.LinesOfCode.RemovedCount,
- CCSessionCount:
record.ClaudeCodeMetrics.CoreMetrics.DistinctSessionCount,
-
- EditToolAccepted:
record.ClaudeCodeMetrics.ToolActions.EditTool.AcceptedCount,
- EditToolRejected:
record.ClaudeCodeMetrics.ToolActions.EditTool.RejectedCount,
- MultiEditToolAccepted:
record.ClaudeCodeMetrics.ToolActions.MultiEditTool.AcceptedCount,
- MultiEditToolRejected:
record.ClaudeCodeMetrics.ToolActions.MultiEditTool.RejectedCount,
- WriteToolAccepted:
record.ClaudeCodeMetrics.ToolActions.WriteTool.AcceptedCount,
- WriteToolRejected:
record.ClaudeCodeMetrics.ToolActions.WriteTool.RejectedCount,
- NotebookEditToolAccepted:
record.ClaudeCodeMetrics.ToolActions.NotebookEditTool.AcceptedCount,
- NotebookEditToolRejected:
record.ClaudeCodeMetrics.ToolActions.NotebookEditTool.RejectedCount,
-
- WebSearchCount: record.WebSearchCount,
+ if isConsole {
+ return extractConsoleUserActivity(data, date,
row.Data)
}
- return []interface{}{activity}, nil
+ return extractEnterpriseUserActivity(data, date,
row.Data)
},
})
if err != nil {
@@ -169,10 +174,99 @@ func ExtractUserActivity(taskCtx plugin.SubTaskContext)
errors.Error {
return extractor.Execute()
}
+func extractEnterpriseUserActivity(data *ClaudeCodeTaskData, date time.Time,
raw []byte) ([]interface{}, errors.Error) {
+ var record userActivityRecord
+ if err := errors.Convert(json.Unmarshal(raw, &record)); err != nil {
+ return nil, err
+ }
+
+ userId := strings.TrimSpace(record.User.Id)
+ if userId == "" {
+ userId = strings.TrimSpace(record.User.EmailAddress)
+ }
+ if userId == "" {
+ return nil, nil
+ }
+
+ activity := &models.ClaudeCodeUserActivity{
+ ConnectionId: data.Options.ConnectionId,
+ ScopeId: data.Options.ScopeId,
+ Date: date,
+ UserId: userId,
+ UserEmail: strings.TrimSpace(record.User.EmailAddress),
+
+ ChatConversationCount:
record.ChatMetrics.DistinctConversationCount,
+ ChatMessageCount: record.ChatMetrics.MessageCount,
+ ChatProjectsCreatedCount:
record.ChatMetrics.DistinctProjectsCreatedCount,
+ ChatProjectsUsedCount:
record.ChatMetrics.DistinctProjectsUsedCount,
+ ChatFilesUploadedCount:
record.ChatMetrics.DistinctFilesUploadedCount,
+ ChatArtifactsCreatedCount:
record.ChatMetrics.DistinctArtifactsCreatedCount,
+ ChatThinkingMessageCount:
record.ChatMetrics.ThinkingMessageCount,
+ ChatSkillsUsedCount:
record.ChatMetrics.DistinctSkillsUsedCount,
+ ChatConnectorsUsedCount:
record.ChatMetrics.ConnectorsUsedCount,
+
+ CCCommitCount:
record.ClaudeCodeMetrics.CoreMetrics.CommitCount,
+ CCPullRequestCount:
record.ClaudeCodeMetrics.CoreMetrics.PullRequestCount,
+ CCLinesAdded:
record.ClaudeCodeMetrics.CoreMetrics.LinesOfCode.AddedCount,
+ CCLinesRemoved:
record.ClaudeCodeMetrics.CoreMetrics.LinesOfCode.RemovedCount,
+ CCSessionCount:
record.ClaudeCodeMetrics.CoreMetrics.DistinctSessionCount,
+
+ EditToolAccepted:
record.ClaudeCodeMetrics.ToolActions.EditTool.AcceptedCount,
+ EditToolRejected:
record.ClaudeCodeMetrics.ToolActions.EditTool.RejectedCount,
+ MultiEditToolAccepted:
record.ClaudeCodeMetrics.ToolActions.MultiEditTool.AcceptedCount,
+ MultiEditToolRejected:
record.ClaudeCodeMetrics.ToolActions.MultiEditTool.RejectedCount,
+ WriteToolAccepted:
record.ClaudeCodeMetrics.ToolActions.WriteTool.AcceptedCount,
+ WriteToolRejected:
record.ClaudeCodeMetrics.ToolActions.WriteTool.RejectedCount,
+ NotebookEditToolAccepted:
record.ClaudeCodeMetrics.ToolActions.NotebookEditTool.AcceptedCount,
+ NotebookEditToolRejected:
record.ClaudeCodeMetrics.ToolActions.NotebookEditTool.RejectedCount,
+
+ WebSearchCount: record.WebSearchCount,
+ }
+ return []interface{}{activity}, nil
+}
+
+func extractConsoleUserActivity(data *ClaudeCodeTaskData, date time.Time, raw
[]byte) ([]interface{}, errors.Error) {
+ var record consoleUserActivityRecord
+ if err := errors.Convert(json.Unmarshal(raw, &record)); err != nil {
+ return nil, err
+ }
+
+ userId := strings.TrimSpace(record.Actor.EmailAddress)
+ if userId == "" {
+ userId = strings.TrimSpace(record.Actor.ApiKeyName)
+ }
+ if userId == "" {
+ return nil, nil
+ }
+
+ activity := &models.ClaudeCodeUserActivity{
+ ConnectionId: data.Options.ConnectionId,
+ ScopeId: data.Options.ScopeId,
+ Date: date,
+ UserId: userId,
+ UserEmail: strings.TrimSpace(record.Actor.EmailAddress),
+
+ CCSessionCount: record.CoreMetrics.NumSessions,
+ CCCommitCount: record.CoreMetrics.CommitsByClaudeCode,
+ CCPullRequestCount: record.CoreMetrics.PullRequestsByClaudeCode,
+ CCLinesAdded: record.CoreMetrics.LinesOfCode.Added,
+ CCLinesRemoved: record.CoreMetrics.LinesOfCode.Removed,
+
+ EditToolAccepted: record.ToolActions.EditTool.Accepted,
+ EditToolRejected: record.ToolActions.EditTool.Rejected,
+ MultiEditToolAccepted:
record.ToolActions.MultiEditTool.Accepted,
+ MultiEditToolRejected:
record.ToolActions.MultiEditTool.Rejected,
+ WriteToolAccepted: record.ToolActions.WriteTool.Accepted,
+ WriteToolRejected: record.ToolActions.WriteTool.Rejected,
+ NotebookEditToolAccepted:
record.ToolActions.NotebookEditTool.Accepted,
+ NotebookEditToolRejected:
record.ToolActions.NotebookEditTool.Rejected,
+ }
+ return []interface{}{activity}, nil
+}
+
// parseAnalyticsDate extracts the date from the raw row input JSON.
// The input is the claudeCodeDayInput or claudeCodeDateRangeInput encoded as
JSON.
func parseAnalyticsDate(rawInput json.RawMessage) (time.Time, errors.Error) {
- // Try day input first.
var dayInput claudeCodeDayInput
if err := json.Unmarshal(rawInput, &dayInput); err == nil &&
dayInput.Day != "" {
t, parseErr := time.Parse("2006-01-02",
strings.TrimSpace(dayInput.Day))
@@ -180,7 +274,6 @@ func parseAnalyticsDate(rawInput json.RawMessage)
(time.Time, errors.Error) {
return utcDate(t), nil
}
}
- // Fall back to date range input (summaries).
var rangeInput claudeCodeDateRangeInput
if err := json.Unmarshal(rawInput, &rangeInput); err == nil &&
rangeInput.StartDate != "" {
t, parseErr := time.Parse("2006-01-02",
strings.TrimSpace(rangeInput.StartDate))