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))

Reply via email to