Skip to content

Commit 7a51deb

Browse files
authored
feat:allow claude organization support (apache#8978)
1 parent edb9b0d commit 7a51deb

5 files changed

Lines changed: 181 additions & 55 deletions

File tree

backend/plugins/claude_code/models/connection.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ func (conn *ClaudeCodeConn) HasUsableCustomHeaders() bool {
6262
return false
6363
}
6464

65+
// IsConsoleApiKey returns true when the token is a Claude Console Admin API key.
66+
// Console keys (sk-ant-admin01-...) have full Admin API access and use
67+
// /v1/organizations/usage_report/claude_code instead of the Enterprise
68+
// /v1/organizations/analytics/* endpoints.
69+
func (conn *ClaudeCodeConn) IsConsoleApiKey() bool {
70+
return strings.HasPrefix(strings.TrimSpace(conn.Token), "sk-ant-admin01-")
71+
}
72+
6573
func (conn *ClaudeCodeConn) HasIncompleteCustomHeaders() bool {
6674
if conn == nil {
6775
return false

backend/plugins/claude_code/tasks/activity_summary_collector.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ func CollectActivitySummary(taskCtx plugin.SubTaskContext) errors.Error {
4444
return nil
4545
}
4646

47+
if connection.IsConsoleApiKey() {
48+
taskCtx.GetLogger().Info("Console API key detected, skipping activity summary collection (no equivalent endpoint)")
49+
return nil
50+
}
51+
4752
apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection)
4853
if err != nil {
4954
return err

backend/plugins/claude_code/tasks/connector_usage_collector.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ func CollectConnectorUsage(taskCtx plugin.SubTaskContext) errors.Error {
4444
return nil
4545
}
4646

47+
if connection.IsConsoleApiKey() {
48+
taskCtx.GetLogger().Info("Console API key detected, skipping connector usage collection (no equivalent endpoint)")
49+
return nil
50+
}
51+
4752
apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection)
4853
if err != nil {
4954
return err

backend/plugins/claude_code/tasks/user_activity_collector.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ import (
3030
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
3131
)
3232

33-
// CollectUserActivity collects per-user daily engagement metrics from /v1/organizations/analytics/users.
33+
// CollectUserActivity collects per-user daily engagement metrics.
34+
// For Claude Enterprise organizations it calls /v1/organizations/analytics/users.
35+
// For Claude Console organizations (sk-ant-admin01-... keys) it calls
36+
// /v1/organizations/usage_report/claude_code.
3437
func CollectUserActivity(taskCtx plugin.SubTaskContext) errors.Error {
3538
data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData)
3639
if !ok {
@@ -49,14 +52,22 @@ func CollectUserActivity(taskCtx plugin.SubTaskContext) errors.Error {
4952
return err
5053
}
5154

55+
isConsole := connection.IsConsoleApiKey()
56+
endpoint := "analytics/users"
57+
urlTemplate := "v1/organizations/analytics/users"
58+
if isConsole {
59+
endpoint = "usage_report/claude_code"
60+
urlTemplate = "v1/organizations/usage_report/claude_code"
61+
}
62+
5263
rawArgs := helper.RawDataSubTaskArgs{
5364
Ctx: taskCtx,
5465
Table: rawUserActivityTable,
5566
Options: claudeCodeRawParams{
5667
ConnectionId: data.Options.ConnectionId,
5768
ScopeId: data.Options.ScopeId,
5869
Organization: connection.Organization,
59-
Endpoint: "analytics/users",
70+
Endpoint: endpoint,
6071
},
6172
}
6273

@@ -73,12 +84,16 @@ func CollectUserActivity(taskCtx plugin.SubTaskContext) errors.Error {
7384
Input: dayIter,
7485
PageSize: 1,
7586
Incremental: true,
76-
UrlTemplate: "v1/organizations/analytics/users",
87+
UrlTemplate: urlTemplate,
7788
GetNextPageCustomData: getNextClaudeCodePageCursor,
7889
Query: func(reqData *helper.RequestData) (url.Values, errors.Error) {
7990
input := reqData.Input.(*claudeCodeDayInput)
8091
query := url.Values{}
81-
query.Set("date", input.Day)
92+
if isConsole {
93+
query.Set("starting_at", input.Day)
94+
} else {
95+
query.Set("date", input.Day)
96+
}
8297
query.Set("limit", fmt.Sprintf("%d", claudeCodeApiPageLimit))
8398
if cursor, ok := reqData.CustomData.(string); ok && strings.TrimSpace(cursor) != "" {
8499
query.Set("page", cursor)

backend/plugins/claude_code/tasks/user_activity_extractor.go

Lines changed: 144 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import (
2828
"github.com/apache/incubator-devlake/plugins/claude_code/models"
2929
)
3030

31+
// ── Enterprise API response types (/v1/organizations/analytics/users) ──────────
32+
3133
// userActivityRecord is the JSON shape returned by /v1/organizations/analytics/users.
3234
type userActivityRecord struct {
3335
User userActivityUser `json:"user"`
@@ -82,6 +84,48 @@ type userActivityCCTools struct {
8284
NotebookEditTool userActivityToolAction `json:"notebook_edit_tool"`
8385
}
8486

87+
// ── Console API response types (/v1/organizations/usage_report/claude_code) ───
88+
89+
// consoleUserActivityRecord is the JSON shape returned by /v1/organizations/usage_report/claude_code.
90+
type consoleUserActivityRecord struct {
91+
Date string `json:"date"`
92+
Actor consoleActor `json:"actor"`
93+
CoreMetrics consoleCoreMetrics `json:"core_metrics"`
94+
ToolActions consoleToolActions `json:"tool_actions"`
95+
}
96+
97+
type consoleActor struct {
98+
Type string `json:"type"`
99+
EmailAddress string `json:"email_address"`
100+
ApiKeyName string `json:"api_key_name"`
101+
}
102+
103+
type consoleCoreMetrics struct {
104+
NumSessions int `json:"num_sessions"`
105+
LinesOfCode consoleLinesOfCode `json:"lines_of_code"`
106+
CommitsByClaudeCode int `json:"commits_by_claude_code"`
107+
PullRequestsByClaudeCode int `json:"pull_requests_by_claude_code"`
108+
}
109+
110+
type consoleLinesOfCode struct {
111+
Added int `json:"added"`
112+
Removed int `json:"removed"`
113+
}
114+
115+
type consoleToolAction struct {
116+
Accepted int `json:"accepted"`
117+
Rejected int `json:"rejected"`
118+
}
119+
120+
type consoleToolActions struct {
121+
EditTool consoleToolAction `json:"edit_tool"`
122+
MultiEditTool consoleToolAction `json:"multi_edit_tool"`
123+
WriteTool consoleToolAction `json:"write_tool"`
124+
NotebookEditTool consoleToolAction `json:"notebook_edit_tool"`
125+
}
126+
127+
// ── Extractor ─────────────────────────────────────────────────────────────────
128+
85129
// ExtractUserActivity parses raw user activity records into tool-layer tables.
86130
func ExtractUserActivity(taskCtx plugin.SubTaskContext) errors.Error {
87131
data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData)
@@ -96,6 +140,12 @@ func ExtractUserActivity(taskCtx plugin.SubTaskContext) errors.Error {
96140
return nil
97141
}
98142

143+
isConsole := connection.IsConsoleApiKey()
144+
endpoint := "analytics/users"
145+
if isConsole {
146+
endpoint = "usage_report/claude_code"
147+
}
148+
99149
extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
100150
RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
101151
Ctx: taskCtx,
@@ -104,63 +154,18 @@ func ExtractUserActivity(taskCtx plugin.SubTaskContext) errors.Error {
104154
ConnectionId: data.Options.ConnectionId,
105155
ScopeId: data.Options.ScopeId,
106156
Organization: connection.Organization,
107-
Endpoint: "analytics/users",
157+
Endpoint: endpoint,
108158
},
109159
},
110160
Extract: func(row *helper.RawData) ([]interface{}, errors.Error) {
111-
var record userActivityRecord
112-
if err := errors.Convert(json.Unmarshal(row.Data, &record)); err != nil {
113-
return nil, err
114-
}
115-
116161
date, parseErr := parseAnalyticsDate(row.Input)
117162
if parseErr != nil {
118163
return nil, parseErr
119164
}
120-
121-
userId := strings.TrimSpace(record.User.Id)
122-
if userId == "" {
123-
userId = strings.TrimSpace(record.User.EmailAddress)
124-
}
125-
if userId == "" {
126-
return nil, nil
127-
}
128-
129-
activity := &models.ClaudeCodeUserActivity{
130-
ConnectionId: data.Options.ConnectionId,
131-
ScopeId: data.Options.ScopeId,
132-
Date: date,
133-
UserId: userId,
134-
UserEmail: strings.TrimSpace(record.User.EmailAddress),
135-
136-
ChatConversationCount: record.ChatMetrics.DistinctConversationCount,
137-
ChatMessageCount: record.ChatMetrics.MessageCount,
138-
ChatProjectsCreatedCount: record.ChatMetrics.DistinctProjectsCreatedCount,
139-
ChatProjectsUsedCount: record.ChatMetrics.DistinctProjectsUsedCount,
140-
ChatFilesUploadedCount: record.ChatMetrics.DistinctFilesUploadedCount,
141-
ChatArtifactsCreatedCount: record.ChatMetrics.DistinctArtifactsCreatedCount,
142-
ChatThinkingMessageCount: record.ChatMetrics.ThinkingMessageCount,
143-
ChatSkillsUsedCount: record.ChatMetrics.DistinctSkillsUsedCount,
144-
ChatConnectorsUsedCount: record.ChatMetrics.ConnectorsUsedCount,
145-
146-
CCCommitCount: record.ClaudeCodeMetrics.CoreMetrics.CommitCount,
147-
CCPullRequestCount: record.ClaudeCodeMetrics.CoreMetrics.PullRequestCount,
148-
CCLinesAdded: record.ClaudeCodeMetrics.CoreMetrics.LinesOfCode.AddedCount,
149-
CCLinesRemoved: record.ClaudeCodeMetrics.CoreMetrics.LinesOfCode.RemovedCount,
150-
CCSessionCount: record.ClaudeCodeMetrics.CoreMetrics.DistinctSessionCount,
151-
152-
EditToolAccepted: record.ClaudeCodeMetrics.ToolActions.EditTool.AcceptedCount,
153-
EditToolRejected: record.ClaudeCodeMetrics.ToolActions.EditTool.RejectedCount,
154-
MultiEditToolAccepted: record.ClaudeCodeMetrics.ToolActions.MultiEditTool.AcceptedCount,
155-
MultiEditToolRejected: record.ClaudeCodeMetrics.ToolActions.MultiEditTool.RejectedCount,
156-
WriteToolAccepted: record.ClaudeCodeMetrics.ToolActions.WriteTool.AcceptedCount,
157-
WriteToolRejected: record.ClaudeCodeMetrics.ToolActions.WriteTool.RejectedCount,
158-
NotebookEditToolAccepted: record.ClaudeCodeMetrics.ToolActions.NotebookEditTool.AcceptedCount,
159-
NotebookEditToolRejected: record.ClaudeCodeMetrics.ToolActions.NotebookEditTool.RejectedCount,
160-
161-
WebSearchCount: record.WebSearchCount,
165+
if isConsole {
166+
return extractConsoleUserActivity(data, date, row.Data)
162167
}
163-
return []interface{}{activity}, nil
168+
return extractEnterpriseUserActivity(data, date, row.Data)
164169
},
165170
})
166171
if err != nil {
@@ -169,18 +174,106 @@ func ExtractUserActivity(taskCtx plugin.SubTaskContext) errors.Error {
169174
return extractor.Execute()
170175
}
171176

177+
func extractEnterpriseUserActivity(data *ClaudeCodeTaskData, date time.Time, raw []byte) ([]interface{}, errors.Error) {
178+
var record userActivityRecord
179+
if err := errors.Convert(json.Unmarshal(raw, &record)); err != nil {
180+
return nil, err
181+
}
182+
183+
userId := strings.TrimSpace(record.User.Id)
184+
if userId == "" {
185+
userId = strings.TrimSpace(record.User.EmailAddress)
186+
}
187+
if userId == "" {
188+
return nil, nil
189+
}
190+
191+
activity := &models.ClaudeCodeUserActivity{
192+
ConnectionId: data.Options.ConnectionId,
193+
ScopeId: data.Options.ScopeId,
194+
Date: date,
195+
UserId: userId,
196+
UserEmail: strings.TrimSpace(record.User.EmailAddress),
197+
198+
ChatConversationCount: record.ChatMetrics.DistinctConversationCount,
199+
ChatMessageCount: record.ChatMetrics.MessageCount,
200+
ChatProjectsCreatedCount: record.ChatMetrics.DistinctProjectsCreatedCount,
201+
ChatProjectsUsedCount: record.ChatMetrics.DistinctProjectsUsedCount,
202+
ChatFilesUploadedCount: record.ChatMetrics.DistinctFilesUploadedCount,
203+
ChatArtifactsCreatedCount: record.ChatMetrics.DistinctArtifactsCreatedCount,
204+
ChatThinkingMessageCount: record.ChatMetrics.ThinkingMessageCount,
205+
ChatSkillsUsedCount: record.ChatMetrics.DistinctSkillsUsedCount,
206+
ChatConnectorsUsedCount: record.ChatMetrics.ConnectorsUsedCount,
207+
208+
CCCommitCount: record.ClaudeCodeMetrics.CoreMetrics.CommitCount,
209+
CCPullRequestCount: record.ClaudeCodeMetrics.CoreMetrics.PullRequestCount,
210+
CCLinesAdded: record.ClaudeCodeMetrics.CoreMetrics.LinesOfCode.AddedCount,
211+
CCLinesRemoved: record.ClaudeCodeMetrics.CoreMetrics.LinesOfCode.RemovedCount,
212+
CCSessionCount: record.ClaudeCodeMetrics.CoreMetrics.DistinctSessionCount,
213+
214+
EditToolAccepted: record.ClaudeCodeMetrics.ToolActions.EditTool.AcceptedCount,
215+
EditToolRejected: record.ClaudeCodeMetrics.ToolActions.EditTool.RejectedCount,
216+
MultiEditToolAccepted: record.ClaudeCodeMetrics.ToolActions.MultiEditTool.AcceptedCount,
217+
MultiEditToolRejected: record.ClaudeCodeMetrics.ToolActions.MultiEditTool.RejectedCount,
218+
WriteToolAccepted: record.ClaudeCodeMetrics.ToolActions.WriteTool.AcceptedCount,
219+
WriteToolRejected: record.ClaudeCodeMetrics.ToolActions.WriteTool.RejectedCount,
220+
NotebookEditToolAccepted: record.ClaudeCodeMetrics.ToolActions.NotebookEditTool.AcceptedCount,
221+
NotebookEditToolRejected: record.ClaudeCodeMetrics.ToolActions.NotebookEditTool.RejectedCount,
222+
223+
WebSearchCount: record.WebSearchCount,
224+
}
225+
return []interface{}{activity}, nil
226+
}
227+
228+
func extractConsoleUserActivity(data *ClaudeCodeTaskData, date time.Time, raw []byte) ([]interface{}, errors.Error) {
229+
var record consoleUserActivityRecord
230+
if err := errors.Convert(json.Unmarshal(raw, &record)); err != nil {
231+
return nil, err
232+
}
233+
234+
userId := strings.TrimSpace(record.Actor.EmailAddress)
235+
if userId == "" {
236+
userId = strings.TrimSpace(record.Actor.ApiKeyName)
237+
}
238+
if userId == "" {
239+
return nil, nil
240+
}
241+
242+
activity := &models.ClaudeCodeUserActivity{
243+
ConnectionId: data.Options.ConnectionId,
244+
ScopeId: data.Options.ScopeId,
245+
Date: date,
246+
UserId: userId,
247+
UserEmail: strings.TrimSpace(record.Actor.EmailAddress),
248+
249+
CCSessionCount: record.CoreMetrics.NumSessions,
250+
CCCommitCount: record.CoreMetrics.CommitsByClaudeCode,
251+
CCPullRequestCount: record.CoreMetrics.PullRequestsByClaudeCode,
252+
CCLinesAdded: record.CoreMetrics.LinesOfCode.Added,
253+
CCLinesRemoved: record.CoreMetrics.LinesOfCode.Removed,
254+
255+
EditToolAccepted: record.ToolActions.EditTool.Accepted,
256+
EditToolRejected: record.ToolActions.EditTool.Rejected,
257+
MultiEditToolAccepted: record.ToolActions.MultiEditTool.Accepted,
258+
MultiEditToolRejected: record.ToolActions.MultiEditTool.Rejected,
259+
WriteToolAccepted: record.ToolActions.WriteTool.Accepted,
260+
WriteToolRejected: record.ToolActions.WriteTool.Rejected,
261+
NotebookEditToolAccepted: record.ToolActions.NotebookEditTool.Accepted,
262+
NotebookEditToolRejected: record.ToolActions.NotebookEditTool.Rejected,
263+
}
264+
return []interface{}{activity}, nil
265+
}
266+
172267
// parseAnalyticsDate extracts the date from the raw row input JSON.
173268
// The input is the claudeCodeDayInput or claudeCodeDateRangeInput encoded as JSON.
174269
func parseAnalyticsDate(rawInput json.RawMessage) (time.Time, errors.Error) {
175-
// Try day input first.
176270
var dayInput claudeCodeDayInput
177271
if err := json.Unmarshal(rawInput, &dayInput); err == nil && dayInput.Day != "" {
178272
t, parseErr := time.Parse("2006-01-02", strings.TrimSpace(dayInput.Day))
179273
if parseErr == nil {
180274
return utcDate(t), nil
181275
}
182276
}
183-
// Fall back to date range input (summaries).
184277
var rangeInput claudeCodeDateRangeInput
185278
if err := json.Unmarshal(rawInput, &rangeInput); err == nil && rangeInput.StartDate != "" {
186279
t, parseErr := time.Parse("2006-01-02", strings.TrimSpace(rangeInput.StartDate))

0 commit comments

Comments
 (0)