Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/plugins/claude_code/models/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions backend/plugins/claude_code/tasks/user_activity_collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -49,14 +52,22 @@ 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,
Options: claudeCodeRawParams{
ConnectionId: data.Options.ConnectionId,
ScopeId: data.Options.ScopeId,
Organization: connection.Organization,
Endpoint: "analytics/users",
Endpoint: endpoint,
},
}

Expand All @@ -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)
Expand Down
195 changes: 144 additions & 51 deletions backend/plugins/claude_code/tasks/user_activity_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -169,18 +174,106 @@ 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))
if parseErr == nil {
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))
Expand Down