diff --git a/backend/plugins/claude_code/models/connection.go b/backend/plugins/claude_code/models/connection.go index 973b710afd6..700b611ae13 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 110c44e54b5..79728f19090 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 076cf00e178..2b885b1ef57 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 16a76472126..7239390be52 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 040b5a8d496..4050a7c2b1f 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))