From 6d262bb9fca1c875d335a2c14be4f8d174a2c20a Mon Sep 17 00:00:00 2001 From: "tamas.albert" Date: Mon, 9 Mar 2026 16:04:03 +0200 Subject: [PATCH] story: Add claude code metrics data integration (cherry picked from commit 78fd47d9e7647ff3ac8b886b2ddb45f1df55e2b4) (cherry picked from commit fa69cf9ec0fa0db6aa5fb0aec2e5ca2796355ffd) (upstream) story: XPL-551: Add license headers (cherry picked from commit a12ece8c1ca65ff27acc14d4630ac3d404d51ce8) --- .../plugins/claude_code/api/blueprint_v200.go | 81 + backend/plugins/claude_code/api/connection.go | 110 ++ .../claude_code/api/connection_test.go | 103 ++ backend/plugins/claude_code/api/init.go | 58 + backend/plugins/claude_code/api/remote_api.go | 113 ++ backend/plugins/claude_code/api/scope.go | 53 + .../plugins/claude_code/api/scope_config.go | 53 + .../claude_code/api/test_connection.go | 70 + backend/plugins/claude_code/claude_code.go | 45 + .../claude_code/impl/connection_helper.go | 25 + backend/plugins/claude_code/impl/impl.go | 170 +++ .../claude_code/models/activity_summary.go | 44 + .../claude_code/models/chat_project.go | 47 + .../plugins/claude_code/models/connection.go | 159 ++ .../claude_code/models/connection_test.go | 84 ++ .../claude_code/models/connector_usage.go | 43 + .../migrationscripts/20260309_initialize.go | 84 ++ .../20260319_add_custom_headers.go | 58 + .../20260319_replace_analytics_tables.go | 207 +++ .../models/migrationscripts/register.go | 29 + backend/plugins/claude_code/models/models.go | 34 + backend/plugins/claude_code/models/scope.go | 102 ++ .../claude_code/models/scope_config.go | 39 + .../plugins/claude_code/models/skill_usage.go | 43 + .../claude_code/models/user_activity.go | 71 + .../service/connection_test_helper.go | 112 ++ .../tasks/activity_summary_collector.go | 97 ++ .../tasks/activity_summary_extractor.go | 99 ++ .../plugins/claude_code/tasks/api_client.go | 81 + .../tasks/chat_project_collector.go | 102 ++ .../tasks/chat_project_extractor.go | 115 ++ .../claude_code/tasks/collector_utils.go | 195 +++ .../claude_code/tasks/collector_utils_test.go | 55 + .../tasks/connector_usage_collector.go | 101 ++ .../tasks/connector_usage_extractor.go | 103 ++ backend/plugins/claude_code/tasks/options.go | 24 + backend/plugins/claude_code/tasks/register.go | 36 + .../plugins/claude_code/tasks/retry_after.go | 44 + .../tasks/skill_usage_collector.go | 101 ++ .../tasks/skill_usage_extractor.go | 103 ++ backend/plugins/claude_code/tasks/subtasks.go | 105 ++ .../plugins/claude_code/tasks/task_data.go | 26 + .../tasks/user_activity_collector.go | 101 ++ .../tasks/user_activity_extractor.go | 192 +++ .../tasks/user_activity_extractor_test.go | 46 + config-ui/src/api/connection/index.ts | 8 + config-ui/src/config/entities.ts | 1 + config-ui/src/features/connections/utils.ts | 14 +- .../components/connection-form/index.tsx | 5 + .../register/claude-code/assets/icon.svg | 4 + .../plugins/register/claude-code/config.tsx | 85 ++ .../connection-fields/custom-headers.tsx | 78 + .../claude-code/connection-fields/index.ts | 21 + .../connection-fields/organization.tsx | 67 + .../claude-code/connection-fields/styled.ts | 25 + .../claude-code/connection-fields/token.tsx | 66 + .../src/plugins/register/claude-code/index.ts | 19 + config-ui/src/plugins/register/index.ts | 2 + config-ui/src/plugins/utils.ts | 9 + config-ui/src/types/connection.ts | 11 + grafana/dashboards/ClaudeCodeAdoption.json | 1295 ++++++++++++++++ .../dashboards/ClaudeCodeAdoptionByUser.json | 1301 +++++++++++++++++ 62 files changed, 6761 insertions(+), 13 deletions(-) create mode 100644 backend/plugins/claude_code/api/blueprint_v200.go create mode 100644 backend/plugins/claude_code/api/connection.go create mode 100644 backend/plugins/claude_code/api/connection_test.go create mode 100644 backend/plugins/claude_code/api/init.go create mode 100644 backend/plugins/claude_code/api/remote_api.go create mode 100644 backend/plugins/claude_code/api/scope.go create mode 100644 backend/plugins/claude_code/api/scope_config.go create mode 100644 backend/plugins/claude_code/api/test_connection.go create mode 100644 backend/plugins/claude_code/claude_code.go create mode 100644 backend/plugins/claude_code/impl/connection_helper.go create mode 100644 backend/plugins/claude_code/impl/impl.go create mode 100644 backend/plugins/claude_code/models/activity_summary.go create mode 100644 backend/plugins/claude_code/models/chat_project.go create mode 100644 backend/plugins/claude_code/models/connection.go create mode 100644 backend/plugins/claude_code/models/connection_test.go create mode 100644 backend/plugins/claude_code/models/connector_usage.go create mode 100644 backend/plugins/claude_code/models/migrationscripts/20260309_initialize.go create mode 100644 backend/plugins/claude_code/models/migrationscripts/20260319_add_custom_headers.go create mode 100644 backend/plugins/claude_code/models/migrationscripts/20260319_replace_analytics_tables.go create mode 100644 backend/plugins/claude_code/models/migrationscripts/register.go create mode 100644 backend/plugins/claude_code/models/models.go create mode 100644 backend/plugins/claude_code/models/scope.go create mode 100644 backend/plugins/claude_code/models/scope_config.go create mode 100644 backend/plugins/claude_code/models/skill_usage.go create mode 100644 backend/plugins/claude_code/models/user_activity.go create mode 100644 backend/plugins/claude_code/service/connection_test_helper.go create mode 100644 backend/plugins/claude_code/tasks/activity_summary_collector.go create mode 100644 backend/plugins/claude_code/tasks/activity_summary_extractor.go create mode 100644 backend/plugins/claude_code/tasks/api_client.go create mode 100644 backend/plugins/claude_code/tasks/chat_project_collector.go create mode 100644 backend/plugins/claude_code/tasks/chat_project_extractor.go create mode 100644 backend/plugins/claude_code/tasks/collector_utils.go create mode 100644 backend/plugins/claude_code/tasks/collector_utils_test.go create mode 100644 backend/plugins/claude_code/tasks/connector_usage_collector.go create mode 100644 backend/plugins/claude_code/tasks/connector_usage_extractor.go create mode 100644 backend/plugins/claude_code/tasks/options.go create mode 100644 backend/plugins/claude_code/tasks/register.go create mode 100644 backend/plugins/claude_code/tasks/retry_after.go create mode 100644 backend/plugins/claude_code/tasks/skill_usage_collector.go create mode 100644 backend/plugins/claude_code/tasks/skill_usage_extractor.go create mode 100644 backend/plugins/claude_code/tasks/subtasks.go create mode 100644 backend/plugins/claude_code/tasks/task_data.go create mode 100644 backend/plugins/claude_code/tasks/user_activity_collector.go create mode 100644 backend/plugins/claude_code/tasks/user_activity_extractor.go create mode 100644 backend/plugins/claude_code/tasks/user_activity_extractor_test.go create mode 100644 config-ui/src/plugins/register/claude-code/assets/icon.svg create mode 100644 config-ui/src/plugins/register/claude-code/config.tsx create mode 100644 config-ui/src/plugins/register/claude-code/connection-fields/custom-headers.tsx create mode 100644 config-ui/src/plugins/register/claude-code/connection-fields/index.ts create mode 100644 config-ui/src/plugins/register/claude-code/connection-fields/organization.tsx create mode 100644 config-ui/src/plugins/register/claude-code/connection-fields/styled.ts create mode 100644 config-ui/src/plugins/register/claude-code/connection-fields/token.tsx create mode 100644 config-ui/src/plugins/register/claude-code/index.ts create mode 100644 grafana/dashboards/ClaudeCodeAdoption.json create mode 100644 grafana/dashboards/ClaudeCodeAdoptionByUser.json diff --git a/backend/plugins/claude_code/api/blueprint_v200.go b/backend/plugins/claude_code/api/blueprint_v200.go new file mode 100644 index 00000000000..3802b70a0e2 --- /dev/null +++ b/backend/plugins/claude_code/api/blueprint_v200.go @@ -0,0 +1,81 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + coreModels "github.com/apache/incubator-devlake/core/models" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/helpers/srvhelper" + "github.com/apache/incubator-devlake/plugins/claude_code/models" + "github.com/apache/incubator-devlake/plugins/claude_code/tasks" +) + +// MakeDataSourcePipelinePlanV200 generates the pipeline plan for blueprint v2.0.0. +func MakeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + _, err := dsHelper.ConnSrv.FindByPk(connectionId) + if err != nil { + return nil, nil, err + } + scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes) + if err != nil { + return nil, nil, err + } + + plan, err := makeDataSourcePipelinePlanV200(subtaskMetas, scopeDetails) + if err != nil { + return nil, nil, err + } + + return plan, nil, nil +} + +func makeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + scopeDetails []*srvhelper.ScopeDetail[models.ClaudeCodeScope, models.ClaudeCodeScopeConfig], +) (coreModels.PipelinePlan, errors.Error) { + plan := make(coreModels.PipelinePlan, len(scopeDetails)) + for i, scopeDetail := range scopeDetails { + stage := plan[i] + if stage == nil { + stage = coreModels.PipelineStage{} + } + + scope := scopeDetail.Scope + task, err := helper.MakePipelinePlanTask( + "claude_code", + subtaskMetas, + nil, + tasks.ClaudeCodeOptions{ + ConnectionId: scope.ConnectionId, + ScopeId: scope.Id, + }, + ) + if err != nil { + return nil, err + } + stage = append(stage, task) + plan[i] = stage + } + return plan, nil +} diff --git a/backend/plugins/claude_code/api/connection.go b/backend/plugins/claude_code/api/connection.go new file mode 100644 index 00000000000..2e581aa73c3 --- /dev/null +++ b/backend/plugins/claude_code/api/connection.go @@ -0,0 +1,110 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "strings" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +// PostConnections creates a new Claude Code connection. +func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.ClaudeCodeConnection{} + if err := helper.Decode(input.Body, connection, vld); err != nil { + return nil, err + } + + connection.Normalize() + if err := validateConnection(connection); err != nil { + return nil, err + } + + if err := connectionHelper.Create(connection, input); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil +} + +func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.ClaudeCodeConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, err + } + if err := (&models.ClaudeCodeConnection{}).MergeFromRequest(connection, input.Body); err != nil { + return nil, errors.Convert(err) + } + connection.Normalize() + if err := validateConnection(connection); err != nil { + return nil, err + } + if err := connectionHelper.SaveWithCreateOrUpdate(connection); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil +} + +func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + conn := &models.ClaudeCodeConnection{} + output, err := connectionHelper.Delete(conn, input) + if err != nil { + return output, err + } + output.Body = conn.Sanitize() + return output, nil +} + +func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var connections []models.ClaudeCodeConnection + if err := connectionHelper.List(&connections); err != nil { + return nil, err + } + for i := range connections { + connections[i] = connections[i].Sanitize() + } + return &plugin.ApiResourceOutput{Body: connections}, nil +} + +func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.ClaudeCodeConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil +} + +func validateConnection(connection *models.ClaudeCodeConnection) errors.Error { + if connection == nil { + return errors.BadInput.New("connection is required") + } + hasToken := strings.TrimSpace(connection.Token) != "" + hasCustomHeaders := len(connection.CustomHeaders) > 0 + if !hasToken && !hasCustomHeaders { + return errors.BadInput.New("either token or at least one custom header is required") + } + if strings.TrimSpace(connection.Organization) == "" { + return errors.BadInput.New("organization is required") + } + if connection.RateLimitPerHour < 0 { + return errors.BadInput.New("rateLimitPerHour must be non-negative") + } + return nil +} diff --git a/backend/plugins/claude_code/api/connection_test.go b/backend/plugins/claude_code/api/connection_test.go new file mode 100644 index 00000000000..c5326981430 --- /dev/null +++ b/backend/plugins/claude_code/api/connection_test.go @@ -0,0 +1,103 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +const ( + testOrganization = "anthropic-labs" + testToken = "sk-ant-example" +) + +func TestValidateConnectionSuccess(t *testing.T) { + connection := &models.ClaudeCodeConnection{ + ClaudeCodeConn: models.ClaudeCodeConn{ + Organization: testOrganization, + Token: testToken, + }, + } + connection.Normalize() + + err := validateConnection(connection) + assert.NoError(t, err) +} + +func TestValidateConnectionNil(t *testing.T) { + err := validateConnection(nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "connection is required") +} + +func TestValidateConnectionMissingOrganization(t *testing.T) { + connection := &models.ClaudeCodeConnection{ + ClaudeCodeConn: models.ClaudeCodeConn{ + Token: testToken, + }, + } + + err := validateConnection(connection) + assert.Error(t, err) + assert.Contains(t, err.Error(), "organization is required") +} + +func TestValidateConnectionMissingToken(t *testing.T) { + connection := &models.ClaudeCodeConnection{ + ClaudeCodeConn: models.ClaudeCodeConn{ + Organization: testOrganization, + }, + } + + err := validateConnection(connection) + assert.Error(t, err) + assert.Contains(t, err.Error(), "either token or at least one custom header is required") +} + +func TestValidateConnectionCustomHeadersWithoutToken(t *testing.T) { + connection := &models.ClaudeCodeConnection{ + ClaudeCodeConn: models.ClaudeCodeConn{ + Organization: testOrganization, + CustomHeaders: []models.CustomHeader{ + {Key: "Ocp-Apim-Subscription-Key", Value: "secret-key"}, + }, + }, + } + connection.Normalize() + + err := validateConnection(connection) + assert.NoError(t, err) +} + +func TestValidateConnectionInvalidRateLimit(t *testing.T) { + connection := &models.ClaudeCodeConnection{ + ClaudeCodeConn: models.ClaudeCodeConn{ + Organization: testOrganization, + Token: testToken, + }, + } + connection.RateLimitPerHour = -1 + + err := validateConnection(connection) + assert.Error(t, err) + assert.Contains(t, err.Error(), "rateLimitPerHour must be non-negative") +} diff --git a/backend/plugins/claude_code/api/init.go b/backend/plugins/claude_code/api/init.go new file mode 100644 index 00000000000..3b27e4a5636 --- /dev/null +++ b/backend/plugins/claude_code/api/init.go @@ -0,0 +1,58 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/go-playground/validator/v10" + + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +var ( + basicRes context.BasicRes + vld *validator.Validate + connectionHelper *helper.ConnectionApiHelper + dsHelper *helper.DsHelper[models.ClaudeCodeConnection, models.ClaudeCodeScope, models.ClaudeCodeScopeConfig] + raProxy *helper.DsRemoteApiProxyHelper[models.ClaudeCodeConnection] + raScopeList *helper.DsRemoteApiScopeListHelper[models.ClaudeCodeConnection, models.ClaudeCodeScope, ClaudeCodeRemotePagination] +) + +// Init stores basic resources and configures shared helpers for API handlers. +func Init(br context.BasicRes, meta plugin.PluginMeta) { + basicRes = br + vld = validator.New() + connectionHelper = helper.NewConnectionHelper(basicRes, vld, meta.Name()) + dsHelper = helper.NewDataSourceHelper[ + models.ClaudeCodeConnection, models.ClaudeCodeScope, models.ClaudeCodeScopeConfig, + ]( + basicRes, + meta.Name(), + []string{"id", "organizationId"}, + func(c models.ClaudeCodeConnection) models.ClaudeCodeConnection { + c.Normalize() + return c.Sanitize() + }, + func(s models.ClaudeCodeScope) models.ClaudeCodeScope { return s }, + nil, + ) + raProxy = helper.NewDsRemoteApiProxyHelper[models.ClaudeCodeConnection](dsHelper.ConnApi.ModelApiHelper) + raScopeList = helper.NewDsRemoteApiScopeListHelper[models.ClaudeCodeConnection, models.ClaudeCodeScope, ClaudeCodeRemotePagination](raProxy, listClaudeCodeRemoteScopes) +} diff --git a/backend/plugins/claude_code/api/remote_api.go b/backend/plugins/claude_code/api/remote_api.go new file mode 100644 index 00000000000..9c2e53ec112 --- /dev/null +++ b/backend/plugins/claude_code/api/remote_api.go @@ -0,0 +1,113 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "strings" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helperapi "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + dsmodels "github.com/apache/incubator-devlake/helpers/pluginhelper/api/models" + "github.com/apache/incubator-devlake/helpers/utils" + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +// ClaudeCodeRemotePagination is a placeholder for remote scope pagination. +// Claude Code currently returns a single organization scope per connection. +type ClaudeCodeRemotePagination struct { + Page int `json:"page"` +} + +func listClaudeCodeRemoteScopes( + connection *models.ClaudeCodeConnection, + _ plugin.ApiClient, + _ string, + _ ClaudeCodeRemotePagination, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.ClaudeCodeScope], + nextPage *ClaudeCodeRemotePagination, + err errors.Error, +) { + if connection == nil { + return nil, nil, errors.BadInput.New("connection is required") + } + + organizationId := strings.TrimSpace(connection.Organization) + if organizationId == "" { + return []dsmodels.DsRemoteApiScopeListEntry[models.ClaudeCodeScope]{}, nil, nil + } + + children = append(children, makeClaudeCodeRemoteScopeEntry(organizationId)) + return children, nil, nil +} + +func makeClaudeCodeRemoteScopeEntry(organizationId string) dsmodels.DsRemoteApiScopeListEntry[models.ClaudeCodeScope] { + organizationId = strings.TrimSpace(organizationId) + return dsmodels.DsRemoteApiScopeListEntry[models.ClaudeCodeScope]{ + Type: helperapi.RAS_ENTRY_TYPE_SCOPE, + Id: organizationId, + Name: organizationId, + FullName: organizationId, + Data: &models.ClaudeCodeScope{ + Id: organizationId, + Organization: organizationId, + Name: organizationId, + FullName: organizationId, + }, + } +} + +// RemoteScopes lists all available scopes for this connection. +func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raScopeList.Get(input) +} + +// SearchRemoteScopes searches scopes for this connection. +func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.ClaudeCodeConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, err + } + + params := &dsmodels.DsRemoteApiScopeSearchParams{ + Page: 1, + PageSize: 50, + } + if err := utils.DecodeMapStruct(input.Query, params, true); err != nil { + return nil, err + } + if err := errors.Convert(vld.Struct(params)); err != nil { + return nil, errors.BadInput.Wrap(err, "invalid params") + } + + children := []dsmodels.DsRemoteApiScopeListEntry[models.ClaudeCodeScope]{} + organizationId := strings.TrimSpace(connection.Organization) + searchLower := strings.ToLower(strings.TrimSpace(params.Search)) + if organizationId != "" && strings.Contains(strings.ToLower(organizationId), searchLower) { + children = append(children, makeClaudeCodeRemoteScopeEntry(organizationId)) + } + + return &plugin.ApiResourceOutput{ + Body: map[string]interface{}{ + "children": children, + "page": params.Page, + "pageSize": params.PageSize, + }, + }, nil +} diff --git a/backend/plugins/claude_code/api/scope.go b/backend/plugins/claude_code/api/scope.go new file mode 100644 index 00000000000..ce8dea71797 --- /dev/null +++ b/backend/plugins/claude_code/api/scope.go @@ -0,0 +1,53 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" +) + +// GetScopeList returns all scopes for a connection. +func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetPage(input) +} + +// PutScopes creates or updates scopes for a connection. +func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.PutMultiple(input) +} + +// GetScope returns a single scope by id. +func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeDetail(input) +} + +// PatchScope partially updates a scope. +func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Patch(input) +} + +// DeleteScope removes a scope. +func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Delete(input) +} + +// GetScopeLatestSyncState returns the latest sync state for a scope. +func GetScopeLatestSyncState(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeLatestSyncState(input) +} diff --git a/backend/plugins/claude_code/api/scope_config.go b/backend/plugins/claude_code/api/scope_config.go new file mode 100644 index 00000000000..571e49e85ca --- /dev/null +++ b/backend/plugins/claude_code/api/scope_config.go @@ -0,0 +1,53 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" +) + +// PostScopeConfig creates a new scope config. +func PostScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Post(input) +} + +// GetScopeConfigList returns all scope configs for a connection. +func GetScopeConfigList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetAll(input) +} + +// GetScopeConfig returns a single scope config by id. +func GetScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetDetail(input) +} + +// PatchScopeConfig partially updates a scope config. +func PatchScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Patch(input) +} + +// DeleteScopeConfig removes a scope config. +func DeleteScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Delete(input) +} + +// GetProjectsByScopeConfig returns projects associated with a scope config. +func GetProjectsByScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetProjectsByScopeConfig(input) +} diff --git a/backend/plugins/claude_code/api/test_connection.go b/backend/plugins/claude_code/api/test_connection.go new file mode 100644 index 00000000000..45a75d45460 --- /dev/null +++ b/backend/plugins/claude_code/api/test_connection.go @@ -0,0 +1,70 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + gocontext "context" + "net/http" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" + "github.com/apache/incubator-devlake/plugins/claude_code/service" +) + +// TestConnection validates a Claude Code connection before saving it. +func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.ClaudeCodeConnection{} + if err := helper.Decode(input.Body, connection, vld); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + + connection.Normalize() + if err := validateConnection(connection); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + + result, err := service.TestConnection(gocontext.Background(), basicRes, connection) + if err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil +} + +// TestExistingConnection validates a stored Claude Code connection with optional overrides. +func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.ClaudeCodeConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, errors.BadInput.Wrap(err, "find connection from db")) + } + if err := (&models.ClaudeCodeConnection{}).MergeFromRequest(connection, input.Body); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, errors.Convert(err)) + } + + connection.Normalize() + if err := validateConnection(connection); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + + result, err := service.TestConnection(gocontext.Background(), basicRes, connection) + if err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil +} diff --git a/backend/plugins/claude_code/claude_code.go b/backend/plugins/claude_code/claude_code.go new file mode 100644 index 00000000000..73fedee29ee --- /dev/null +++ b/backend/plugins/claude_code/claude_code.go @@ -0,0 +1,45 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/apache/incubator-devlake/core/runner" + "github.com/apache/incubator-devlake/plugins/claude_code/impl" + "github.com/spf13/cobra" +) + +var PluginEntry impl.ClaudeCode + +// standalone mode for debugging collectors. +func main() { + cmd := &cobra.Command{Use: "claude_code"} + connectionId := cmd.Flags().Uint64P("connectionId", "c", 0, "claude_code connection id") + scopeId := cmd.Flags().StringP("scopeId", "s", "", "claude_code scope id (organization)") + timeAfter := cmd.Flags().StringP("timeAfter", "a", "", "collect data created after the specified time") + + _ = cmd.MarkFlagRequired("connectionId") + _ = cmd.MarkFlagRequired("scopeId") + + cmd.Run = func(cmd *cobra.Command, args []string) { + runner.DirectRun(cmd, args, PluginEntry, map[string]interface{}{ + "connectionId": *connectionId, + "scopeId": *scopeId, + }, *timeAfter) + } + runner.RunCmd(cmd) +} diff --git a/backend/plugins/claude_code/impl/connection_helper.go b/backend/plugins/claude_code/impl/connection_helper.go new file mode 100644 index 00000000000..8b058d5344e --- /dev/null +++ b/backend/plugins/claude_code/impl/connection_helper.go @@ -0,0 +1,25 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import "github.com/apache/incubator-devlake/plugins/claude_code/models" + +// NormalizeConnection ensures required defaults are set before use. +func NormalizeConnection(connection *models.ClaudeCodeConnection) { + connection.Normalize() +} diff --git a/backend/plugins/claude_code/impl/impl.go b/backend/plugins/claude_code/impl/impl.go new file mode 100644 index 00000000000..4e7d2b136d1 --- /dev/null +++ b/backend/plugins/claude_code/impl/impl.go @@ -0,0 +1,170 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + coreModels "github.com/apache/incubator-devlake/core/models" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" + "github.com/apache/incubator-devlake/plugins/claude_code/models/migrationscripts" + "github.com/apache/incubator-devlake/plugins/claude_code/tasks" +) + +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginTask + plugin.PluginApi + plugin.PluginModel + plugin.PluginSource + plugin.DataSourcePluginBlueprintV200 + plugin.PluginMigration + plugin.CloseablePluginTask +} = (*ClaudeCode)(nil) + +// ClaudeCode is the plugin entrypoint implementing DevLake interfaces. +type ClaudeCode struct{} + +func (p ClaudeCode) Init(basicRes context.BasicRes) errors.Error { + api.Init(basicRes, p) + return nil +} + +func (p ClaudeCode) Description() string { + return "Collect Claude Code usage analytics and productivity metrics" +} + +func (p ClaudeCode) Name() string { + return "claude_code" +} + +func (p ClaudeCode) Connection() dal.Tabler { + return &models.ClaudeCodeConnection{} +} + +func (p ClaudeCode) Scope() plugin.ToolLayerScope { + return &models.ClaudeCodeScope{} +} + +func (p ClaudeCode) ScopeConfig() dal.Tabler { + return &models.ClaudeCodeScopeConfig{} +} + +func (p ClaudeCode) GetTablesInfo() []dal.Tabler { + return models.GetTablesInfo() +} + +func (p ClaudeCode) SubTaskMetas() []plugin.SubTaskMeta { + return tasks.GetSubTaskMetas() +} + +func (p ClaudeCode) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) { + var op tasks.ClaudeCodeOptions + if err := helper.Decode(options, &op, nil); err != nil { + return nil, err + } + + connectionHelper := helper.NewConnectionHelper(taskCtx, nil, p.Name()) + connection := &models.ClaudeCodeConnection{} + if err := connectionHelper.FirstById(connection, op.ConnectionId); err != nil { + return nil, err + } + + NormalizeConnection(connection) + + taskData := &tasks.ClaudeCodeTaskData{ + Options: &op, + Connection: connection, + } + + return taskData, nil +} + +func (p ClaudeCode) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{ + "test": { + "POST": api.TestConnection, + }, + "connections": { + "POST": api.PostConnections, + "GET": api.ListConnections, + }, + "connections/:connectionId": { + "GET": api.GetConnection, + "PATCH": api.PatchConnection, + "DELETE": api.DeleteConnection, + }, + "connections/:connectionId/test": { + "POST": api.TestExistingConnection, + }, + "connections/:connectionId/scopes": { + "GET": api.GetScopeList, + "PUT": api.PutScopes, + }, + "connections/:connectionId/scopes/:scopeId": { + "GET": api.GetScope, + "PATCH": api.PatchScope, + "DELETE": api.DeleteScope, + }, + "connections/:connectionId/scopes/:scopeId/latest-sync-state": { + "GET": api.GetScopeLatestSyncState, + }, + "connections/:connectionId/remote-scopes": { + "GET": api.RemoteScopes, + }, + "connections/:connectionId/search-remote-scopes": { + "GET": api.SearchRemoteScopes, + }, + "connections/:connectionId/scope-configs": { + "POST": api.PostScopeConfig, + "GET": api.GetScopeConfigList, + }, + "connections/:connectionId/scope-configs/:scopeConfigId": { + "GET": api.GetScopeConfig, + "PATCH": api.PatchScopeConfig, + "DELETE": api.DeleteScopeConfig, + }, + "scope-config/:scopeConfigId/projects": { + "GET": api.GetProjectsByScopeConfig, + }, + } +} + +func (p ClaudeCode) MakeDataSourcePipelinePlanV200( + connectionId uint64, + scopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, scopes) +} + +func (p ClaudeCode) RootPkgPath() string { + return "github.com/apache/incubator-devlake/plugins/claude_code" +} + +func (p ClaudeCode) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +func (p ClaudeCode) Close(taskCtx plugin.TaskContext) errors.Error { + return nil +} diff --git a/backend/plugins/claude_code/models/activity_summary.go b/backend/plugins/claude_code/models/activity_summary.go new file mode 100644 index 00000000000..3e81f6b0676 --- /dev/null +++ b/backend/plugins/claude_code/models/activity_summary.go @@ -0,0 +1,44 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" +) + +// ClaudeCodeActivitySummary captures daily organisation-level engagement and seat +// utilisation from the /v1/organizations/analytics/summaries endpoint. +type ClaudeCodeActivitySummary struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Date time.Time `gorm:"primaryKey;type:date" json:"date"` // = starting_date + + DailyActiveUserCount int `json:"dailyActiveUserCount"` + WeeklyActiveUserCount int `json:"weeklyActiveUserCount"` + MonthlyActiveUserCount int `json:"monthlyActiveUserCount"` + AssignedSeatCount int `json:"assignedSeatCount"` + PendingInviteCount int `json:"pendingInviteCount"` + + common.NoPKModel +} + +func (ClaudeCodeActivitySummary) TableName() string { + return "_tool_claude_code_activity_summary" +} diff --git a/backend/plugins/claude_code/models/chat_project.go b/backend/plugins/claude_code/models/chat_project.go new file mode 100644 index 00000000000..63eaff010de --- /dev/null +++ b/backend/plugins/claude_code/models/chat_project.go @@ -0,0 +1,47 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" +) + +// ClaudeCodeChatProject captures per-project daily usage from the +// /v1/organizations/analytics/apps/chat/projects endpoint. +type ClaudeCodeChatProject struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Date time.Time `gorm:"primaryKey;type:date" json:"date"` + ProjectId string `gorm:"primaryKey;type:varchar(255)" json:"projectId"` + + ProjectName string `json:"projectName" gorm:"type:varchar(255)"` + DistinctUserCount int `json:"distinctUserCount"` + ConversationCount int `json:"conversationCount"` + MessageCount int `json:"messageCount"` + CreatedAt time.Time `json:"createdAt"` + CreatedById string `json:"createdById" gorm:"type:varchar(255)"` + CreatedByEmail string `json:"createdByEmail" gorm:"type:varchar(255);index"` + + common.NoPKModel +} + +func (ClaudeCodeChatProject) TableName() string { + return "_tool_claude_code_chat_project" +} diff --git a/backend/plugins/claude_code/models/connection.go b/backend/plugins/claude_code/models/connection.go new file mode 100644 index 00000000000..0aafcf12bdf --- /dev/null +++ b/backend/plugins/claude_code/models/connection.go @@ -0,0 +1,159 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "net/http" + "strings" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/utils" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +const ( + // DefaultEndpoint is the Anthropic API base URL. + DefaultEndpoint = "https://api.anthropic.com" + // DefaultRateLimitPerHour is a conservative default for Admin API calls. + DefaultRateLimitPerHour = 1000 + // AnthropicVersion is the required API version header value. + AnthropicVersion = "2023-06-01" +) + +// CustomHeader represents a single HTTP header key-value pair for middleware authentication. +type CustomHeader struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// ClaudeCodeConn stores Anthropic Claude Code connection settings. +type ClaudeCodeConn struct { + helper.RestConnection `mapstructure:",squash"` + + Token string `mapstructure:"token" json:"token"` + Organization string `mapstructure:"organization" json:"organization" gorm:"type:varchar(255)"` + CustomHeaders []CustomHeader `mapstructure:"customHeaders" json:"customHeaders" gorm:"type:json;serializer:json"` +} + +// SetupAuthentication implements plugin.ApiAuthenticator so helper.NewApiClientFromConnection +// can attach the required headers for Anthropic Admin API requests. +// +// When Token is set, the standard Anthropic auth headers (x-api-key, anthropic-version) are +// added. When connecting through a middleware, leave Token empty and supply CustomHeaders instead. +func (conn *ClaudeCodeConn) SetupAuthentication(request *http.Request) errors.Error { + if conn == nil { + return errors.BadInput.New("connection is required") + } + + request.Header.Set("User-Agent", "DevLake/1.0.0") + + if key := strings.TrimSpace(conn.Token); key != "" { + request.Header.Set("x-api-key", key) + request.Header.Set("anthropic-version", AnthropicVersion) + } + + for _, h := range conn.CustomHeaders { + if strings.TrimSpace(h.Key) != "" { + request.Header.Set(h.Key, h.Value) + } + } + + return nil +} + +// Sanitize returns a copy of the conn with sensitive fields masked. +func (conn *ClaudeCodeConn) Sanitize() ClaudeCodeConn { + if conn == nil { + return ClaudeCodeConn{} + } + clone := *conn + clone.Token = utils.SanitizeString(clone.Token) + sanitizedHeaders := make([]CustomHeader, len(clone.CustomHeaders)) + for i, h := range clone.CustomHeaders { + sanitizedHeaders[i] = CustomHeader{Key: h.Key, Value: utils.SanitizeString(h.Value)} + } + clone.CustomHeaders = sanitizedHeaders + return clone +} + +// ClaudeCodeConnection persists connection details with metadata required by DevLake. +type ClaudeCodeConnection struct { + helper.BaseConnection `mapstructure:",squash"` + ClaudeCodeConn `mapstructure:",squash"` +} + +func (ClaudeCodeConnection) TableName() string { + return "_tool_claude_code_connections" +} + +func (connection ClaudeCodeConnection) Sanitize() ClaudeCodeConnection { + connection.ClaudeCodeConn = connection.ClaudeCodeConn.Sanitize() + return connection +} + +// Normalize applies default connection values where necessary. +func (connection *ClaudeCodeConnection) Normalize() { + if connection == nil { + return + } + if connection.Endpoint == "" { + connection.Endpoint = DefaultEndpoint + } + if connection.RateLimitPerHour <= 0 { + connection.RateLimitPerHour = DefaultRateLimitPerHour + } +} + +// MergeFromRequest applies a partial update from an HTTP PATCH body, +// preserving original secret values (Token and custom header values) when +// the caller sends back sanitized placeholders. +func (connection *ClaudeCodeConnection) MergeFromRequest(target *ClaudeCodeConnection, body map[string]interface{}) error { + if target == nil { + return nil + } + originalKey := target.Token + originalHeaders := append([]CustomHeader(nil), target.CustomHeaders...) + originalHeaderValues := make(map[string]string, len(originalHeaders)) + for _, h := range originalHeaders { + originalHeaderValues[h.Key] = h.Value + } + + if err := helper.DecodeMapStruct(body, target, true); err != nil { + return err + } + + sanitizedOriginal := utils.SanitizeString(originalKey) + if target.Token == "" || target.Token == sanitizedOriginal { + target.Token = originalKey + } + + for i, h := range target.CustomHeaders { + if orig, ok := originalHeaderValues[h.Key]; ok { + if h.Value == "" || h.Value == utils.SanitizeString(orig) { + target.CustomHeaders[i].Value = orig + continue + } + } + + if i < len(originalHeaders) && h.Value != "" && h.Value == utils.SanitizeString(originalHeaders[i].Value) { + target.CustomHeaders[i].Value = originalHeaders[i].Value + } + } + + return nil +} diff --git a/backend/plugins/claude_code/models/connection_test.go b/backend/plugins/claude_code/models/connection_test.go new file mode 100644 index 00000000000..1cb0d55f54b --- /dev/null +++ b/backend/plugins/claude_code/models/connection_test.go @@ -0,0 +1,84 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/apache/incubator-devlake/core/utils" +) + +const ( + testHeaderKey = "Ocp-Apim-Subscription-Key" + testHeaderValue = "secret-key" +) + +func TestMergeFromRequestPreservesSanitizedSecrets(t *testing.T) { + connection := &ClaudeCodeConnection{ + ClaudeCodeConn: ClaudeCodeConn{ + Organization: "anthropic-labs", + Token: "sk-ant-example", + CustomHeaders: []CustomHeader{ + {Key: testHeaderKey, Value: testHeaderValue}, + }, + }, + } + + body := map[string]interface{}{ + "token": utils.SanitizeString(connection.Token), + "customHeaders": []map[string]interface{}{ + { + "key": connection.CustomHeaders[0].Key, + "value": utils.SanitizeString(connection.CustomHeaders[0].Value), + }, + }, + } + + err := (&ClaudeCodeConnection{}).MergeFromRequest(connection, body) + + assert.NoError(t, err) + assert.Equal(t, "sk-ant-example", connection.Token) + assert.Equal(t, []CustomHeader{{Key: testHeaderKey, Value: testHeaderValue}}, connection.CustomHeaders) +} + +func TestMergeFromRequestPreservesSanitizedHeaderValueWhenKeyChanges(t *testing.T) { + connection := &ClaudeCodeConnection{ + ClaudeCodeConn: ClaudeCodeConn{ + Organization: "anthropic-labs", + CustomHeaders: []CustomHeader{ + {Key: testHeaderKey, Value: testHeaderValue}, + }, + }, + } + + body := map[string]interface{}{ + "customHeaders": []map[string]interface{}{ + { + "key": "X-Subscription-Key", + "value": utils.SanitizeString(connection.CustomHeaders[0].Value), + }, + }, + } + + err := (&ClaudeCodeConnection{}).MergeFromRequest(connection, body) + + assert.NoError(t, err) + assert.Equal(t, []CustomHeader{{Key: "X-Subscription-Key", Value: testHeaderValue}}, connection.CustomHeaders) +} diff --git a/backend/plugins/claude_code/models/connector_usage.go b/backend/plugins/claude_code/models/connector_usage.go new file mode 100644 index 00000000000..3dca5305e46 --- /dev/null +++ b/backend/plugins/claude_code/models/connector_usage.go @@ -0,0 +1,43 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" +) + +// ClaudeCodeConnectorUsage captures per-connector daily usage from the +// /v1/organizations/analytics/connectors endpoint. +type ClaudeCodeConnectorUsage struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Date time.Time `gorm:"primaryKey;type:date" json:"date"` + ConnectorName string `gorm:"primaryKey;type:varchar(255)" json:"connectorName"` + + DistinctUserCount int `json:"distinctUserCount"` + ChatConversationCount int `json:"chatConversationCount"` + CCSessionCount int `json:"ccSessionCount"` + + common.NoPKModel +} + +func (ClaudeCodeConnectorUsage) TableName() string { + return "_tool_claude_code_connector_usage" +} diff --git a/backend/plugins/claude_code/models/migrationscripts/20260309_initialize.go b/backend/plugins/claude_code/models/migrationscripts/20260309_initialize.go new file mode 100644 index 00000000000..8897bc69a8f --- /dev/null +++ b/backend/plugins/claude_code/models/migrationscripts/20260309_initialize.go @@ -0,0 +1,84 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +// addClaudeCodeInitialTables creates the initial Claude Code tool-layer tables. +type addClaudeCodeInitialTables struct{} + +func (script *addClaudeCodeInitialTables) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &claudeCodeConnection20260309{}, + &claudeCodeScope20260309{}, + &claudeCodeScopeConfig20260309{}, + ) +} + +type claudeCodeConnection20260309 struct { + archived.Model + Name string `gorm:"type:varchar(100);uniqueIndex" json:"name"` + Endpoint string `gorm:"type:varchar(255)" json:"endpoint"` + Proxy string `gorm:"type:varchar(255)" json:"proxy"` + RateLimitPerHour int `json:"rateLimitPerHour"` + Token string `json:"token"` + Organization string `gorm:"type:varchar(255)" json:"organization"` +} + +func (claudeCodeConnection20260309) TableName() string { + return "_tool_claude_code_connections" +} + +type claudeCodeScope20260309 struct { + archived.NoPKModel + ConnectionId uint64 `json:"connectionId" gorm:"primaryKey"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty"` + Id string `json:"id" gorm:"primaryKey;type:varchar(255)"` + Organization string `json:"organization" gorm:"type:varchar(255)"` + Name string `json:"name" gorm:"type:varchar(255)"` + FullName string `json:"fullName" gorm:"type:varchar(255)"` +} + +func (claudeCodeScope20260309) TableName() string { + return "_tool_claude_code_scopes" +} + +type claudeCodeScopeConfig20260309 struct { + archived.Model + Entities []string `gorm:"type:json;serializer:json" json:"entities" mapstructure:"entities"` + ConnectionId uint64 `json:"connectionId" gorm:"index" validate:"required" mapstructure:"connectionId,omitempty"` + Name string `mapstructure:"name" json:"name" gorm:"type:varchar(255);uniqueIndex" validate:"required"` +} + +func (claudeCodeScopeConfig20260309) TableName() string { + return "_tool_claude_code_scope_configs" +} + +func (*addClaudeCodeInitialTables) Version() uint64 { + return 20260309000000 +} + +func (*addClaudeCodeInitialTables) Name() string { + return "claude-code init tables" +} diff --git a/backend/plugins/claude_code/models/migrationscripts/20260319_add_custom_headers.go b/backend/plugins/claude_code/models/migrationscripts/20260319_add_custom_headers.go new file mode 100644 index 00000000000..ba9db8e6dc1 --- /dev/null +++ b/backend/plugins/claude_code/models/migrationscripts/20260319_add_custom_headers.go @@ -0,0 +1,58 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +// addClaudeCodeCustomHeaders adds the custom_headers column to the connections table. +type addClaudeCodeCustomHeaders struct{} + +func (script *addClaudeCodeCustomHeaders) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &claudeCodeConnection20260319{}, + ) +} + +type claudeCodeConnection20260319 struct { + archived.Model + Name string `gorm:"type:varchar(100);uniqueIndex" json:"name"` + Endpoint string `gorm:"type:varchar(255)" json:"endpoint"` + Proxy string `gorm:"type:varchar(255)" json:"proxy"` + RateLimitPerHour int `json:"rateLimitPerHour"` + Token string `json:"token"` + Organization string `gorm:"type:varchar(255)" json:"organization"` + CustomHeaders string `gorm:"type:json" json:"customHeaders"` +} + +func (claudeCodeConnection20260319) TableName() string { + return "_tool_claude_code_connections" +} + +func (*addClaudeCodeCustomHeaders) Version() uint64 { + return 20260319000000 +} + +func (*addClaudeCodeCustomHeaders) Name() string { + return "claude-code add custom headers to connections" +} diff --git a/backend/plugins/claude_code/models/migrationscripts/20260319_replace_analytics_tables.go b/backend/plugins/claude_code/models/migrationscripts/20260319_replace_analytics_tables.go new file mode 100644 index 00000000000..415d191b222 --- /dev/null +++ b/backend/plugins/claude_code/models/migrationscripts/20260319_replace_analytics_tables.go @@ -0,0 +1,207 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "encoding/json" + "time" + + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +// replaceClaudeCodeAnalyticsTables drops the old deprecated endpoint tables and +// creates the five new analytics endpoint tables. +type replaceClaudeCodeAnalyticsTables struct{} + +func (*replaceClaudeCodeAnalyticsTables) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &ccUserActivity20260319{}, + &ccActivitySummary20260319{}, + &ccChatProject20260319{}, + &ccSkillUsage20260319{}, + &ccConnectorUsage20260319{}, + &ccRawUserActivity20260319{}, + &ccRawActivitySummary20260319{}, + &ccRawChatProject20260319{}, + &ccRawSkillUsage20260319{}, + &ccRawConnectorUsage20260319{}, + ) +} + +func (*replaceClaudeCodeAnalyticsTables) Version() uint64 { return 20260319000001 } +func (*replaceClaudeCodeAnalyticsTables) Name() string { + return "claude-code replace deprecated analytics tables" +} + +// ── Tool-layer table snapshots ──────────────────────────────────────────────── + +type ccUserActivity20260319 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + UserId string `gorm:"primaryKey;type:varchar(255)"` + UserEmail string `gorm:"type:varchar(255);index"` + + ChatConversationCount int + ChatMessageCount int + ChatProjectsCreatedCount int + ChatProjectsUsedCount int + ChatFilesUploadedCount int + ChatArtifactsCreatedCount int + ChatThinkingMessageCount int + ChatSkillsUsedCount int + ChatConnectorsUsedCount int + + CCCommitCount int + CCPullRequestCount int + CCLinesAdded int + CCLinesRemoved int + CCSessionCount int + + EditToolAccepted int + EditToolRejected int + MultiEditToolAccepted int + MultiEditToolRejected int + WriteToolAccepted int + WriteToolRejected int + NotebookEditToolAccepted int + NotebookEditToolRejected int + + WebSearchCount int + archived.NoPKModel +} + +func (ccUserActivity20260319) TableName() string { return "_tool_claude_code_user_activity" } + +type ccActivitySummary20260319 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + DailyActiveUserCount int + WeeklyActiveUserCount int + MonthlyActiveUserCount int + AssignedSeatCount int + PendingInviteCount int + archived.NoPKModel +} + +func (ccActivitySummary20260319) TableName() string { return "_tool_claude_code_activity_summary" } + +type ccChatProject20260319 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + ProjectId string `gorm:"primaryKey;type:varchar(255)"` + ProjectName string `gorm:"type:varchar(255)"` + DistinctUserCount int + ConversationCount int + MessageCount int + CreatedAt time.Time + CreatedById string `gorm:"type:varchar(255)"` + CreatedByEmail string `gorm:"type:varchar(255);index"` + archived.NoPKModel +} + +func (ccChatProject20260319) TableName() string { return "_tool_claude_code_chat_project" } + +type ccSkillUsage20260319 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + SkillName string `gorm:"primaryKey;type:varchar(255)"` + DistinctUserCount int + ChatConversationCount int + CCSessionCount int + archived.NoPKModel +} + +func (ccSkillUsage20260319) TableName() string { return "_tool_claude_code_skill_usage" } + +type ccConnectorUsage20260319 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + ConnectorName string `gorm:"primaryKey;type:varchar(255)"` + DistinctUserCount int + ChatConversationCount int + CCSessionCount int + archived.NoPKModel +} + +func (ccConnectorUsage20260319) TableName() string { return "_tool_claude_code_connector_usage" } + +// ── Raw table snapshots ────────────────────────────────────────────────────── + +type ccRawUserActivity20260319 struct { + ID uint64 `gorm:"primaryKey"` + Params string `gorm:"type:varchar(255);index"` + Data []byte + Url string + Input json.RawMessage `gorm:"type:json"` + CreatedAt time.Time `gorm:"index"` +} + +func (ccRawUserActivity20260319) TableName() string { return "_raw_claude_code_user_activity" } + +type ccRawActivitySummary20260319 struct { + ID uint64 `gorm:"primaryKey"` + Params string `gorm:"type:varchar(255);index"` + Data []byte + Url string + Input json.RawMessage `gorm:"type:json"` + CreatedAt time.Time `gorm:"index"` +} + +func (ccRawActivitySummary20260319) TableName() string { return "_raw_claude_code_activity_summary" } + +type ccRawChatProject20260319 struct { + ID uint64 `gorm:"primaryKey"` + Params string `gorm:"type:varchar(255);index"` + Data []byte + Url string + Input json.RawMessage `gorm:"type:json"` + CreatedAt time.Time `gorm:"index"` +} + +func (ccRawChatProject20260319) TableName() string { return "_raw_claude_code_chat_project" } + +type ccRawSkillUsage20260319 struct { + ID uint64 `gorm:"primaryKey"` + Params string `gorm:"type:varchar(255);index"` + Data []byte + Url string + Input json.RawMessage `gorm:"type:json"` + CreatedAt time.Time `gorm:"index"` +} + +func (ccRawSkillUsage20260319) TableName() string { return "_raw_claude_code_skill_usage" } + +type ccRawConnectorUsage20260319 struct { + ID uint64 `gorm:"primaryKey"` + Params string `gorm:"type:varchar(255);index"` + Data []byte + Url string + Input json.RawMessage `gorm:"type:json"` + CreatedAt time.Time `gorm:"index"` +} + +func (ccRawConnectorUsage20260319) TableName() string { return "_raw_claude_code_connector_usage" } diff --git a/backend/plugins/claude_code/models/migrationscripts/register.go b/backend/plugins/claude_code/models/migrationscripts/register.go new file mode 100644 index 00000000000..e62d15e156b --- /dev/null +++ b/backend/plugins/claude_code/models/migrationscripts/register.go @@ -0,0 +1,29 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import "github.com/apache/incubator-devlake/core/plugin" + +// All returns the ordered list of migration scripts for the Claude Code plugin. +func All() []plugin.MigrationScript { + return []plugin.MigrationScript{ + new(addClaudeCodeInitialTables), + new(addClaudeCodeCustomHeaders), + new(replaceClaudeCodeAnalyticsTables), + } +} diff --git a/backend/plugins/claude_code/models/models.go b/backend/plugins/claude_code/models/models.go new file mode 100644 index 00000000000..c1ee7d8042f --- /dev/null +++ b/backend/plugins/claude_code/models/models.go @@ -0,0 +1,34 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import "github.com/apache/incubator-devlake/core/dal" + +// GetTablesInfo returns the list of tool-layer tables managed by the Claude Code plugin. +func GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + &ClaudeCodeConnection{}, + &ClaudeCodeScope{}, + &ClaudeCodeScopeConfig{}, + &ClaudeCodeUserActivity{}, + &ClaudeCodeActivitySummary{}, + &ClaudeCodeChatProject{}, + &ClaudeCodeSkillUsage{}, + &ClaudeCodeConnectorUsage{}, + } +} diff --git a/backend/plugins/claude_code/models/scope.go b/backend/plugins/claude_code/models/scope.go new file mode 100644 index 00000000000..9f085a5fe95 --- /dev/null +++ b/backend/plugins/claude_code/models/scope.go @@ -0,0 +1,102 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "strings" + + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/plugin" + "gorm.io/gorm" +) + +// ClaudeCodeScope represents an organization-level collection scope. +type ClaudeCodeScope struct { + common.Scope `mapstructure:",squash"` + Id string `json:"id" mapstructure:"id" gorm:"primaryKey;type:varchar(255)"` + Organization string `json:"organization" mapstructure:"organization" gorm:"type:varchar(255)"` + Name string `json:"name" mapstructure:"name" gorm:"type:varchar(255)"` + FullName string `json:"fullName" mapstructure:"fullName" gorm:"type:varchar(255)"` +} + +func (ClaudeCodeScope) TableName() string { + return "_tool_claude_code_scopes" +} + +func (s *ClaudeCodeScope) BeforeSave(tx *gorm.DB) error { + if s == nil { + return nil + } + + s.Id = strings.TrimSpace(s.Id) + s.Organization = strings.TrimSpace(s.Organization) + s.Name = strings.TrimSpace(s.Name) + s.FullName = strings.TrimSpace(s.FullName) + + if s.Organization == "" { + s.Organization = s.Id + } + if s.Id == "" { + s.Id = s.Organization + } + if s.Name == "" { + s.Name = s.ScopeName() + } + if s.FullName == "" { + s.FullName = s.ScopeFullName() + } + + return nil + +} + +func (s ClaudeCodeScope) ScopeId() string { + return s.Id +} + +func (s ClaudeCodeScope) ScopeName() string { + if s.Name != "" { + return s.Name + } + if s.Organization != "" { + return s.Organization + } + return s.Id +} + +func (s ClaudeCodeScope) ScopeFullName() string { + if s.FullName != "" { + return s.FullName + } + return s.ScopeName() +} + +func (s ClaudeCodeScope) ScopeParams() interface{} { + return &ClaudeCodeScopeParams{ + ConnectionId: s.ConnectionId, + ScopeId: s.Id, + } +} + +// ClaudeCodeScopeParams is returned for blueprint configuration. +type ClaudeCodeScopeParams struct { + ConnectionId uint64 `json:"connectionId"` + ScopeId string `json:"scopeId"` +} + +var _ plugin.ToolLayerScope = (*ClaudeCodeScope)(nil) diff --git a/backend/plugins/claude_code/models/scope_config.go b/backend/plugins/claude_code/models/scope_config.go new file mode 100644 index 00000000000..22f54f3a983 --- /dev/null +++ b/backend/plugins/claude_code/models/scope_config.go @@ -0,0 +1,39 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/plugin" +) + +var _ plugin.ToolLayerScopeConfig = (*ClaudeCodeScopeConfig)(nil) + +// ClaudeCodeScopeConfig contains configuration for Claude Code data scope. +type ClaudeCodeScopeConfig struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` +} + +func (ClaudeCodeScopeConfig) TableName() string { + return "_tool_claude_code_scope_configs" +} + +// GetConnectionId implements plugin.ToolLayerScopeConfig. +func (sc ClaudeCodeScopeConfig) GetConnectionId() uint64 { + return sc.ConnectionId +} diff --git a/backend/plugins/claude_code/models/skill_usage.go b/backend/plugins/claude_code/models/skill_usage.go new file mode 100644 index 00000000000..3ca4d6fdc8e --- /dev/null +++ b/backend/plugins/claude_code/models/skill_usage.go @@ -0,0 +1,43 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" +) + +// ClaudeCodeSkillUsage captures per-skill daily usage from the +// /v1/organizations/analytics/skills endpoint. +type ClaudeCodeSkillUsage struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Date time.Time `gorm:"primaryKey;type:date" json:"date"` + SkillName string `gorm:"primaryKey;type:varchar(255)" json:"skillName"` + + DistinctUserCount int `json:"distinctUserCount"` + ChatConversationCount int `json:"chatConversationCount"` + CCSessionCount int `json:"ccSessionCount"` + + common.NoPKModel +} + +func (ClaudeCodeSkillUsage) TableName() string { + return "_tool_claude_code_skill_usage" +} diff --git a/backend/plugins/claude_code/models/user_activity.go b/backend/plugins/claude_code/models/user_activity.go new file mode 100644 index 00000000000..6385ae82951 --- /dev/null +++ b/backend/plugins/claude_code/models/user_activity.go @@ -0,0 +1,71 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" +) + +// ClaudeCodeUserActivity captures per-user daily engagement metrics from the +// /v1/organizations/analytics/users endpoint. +type ClaudeCodeUserActivity struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Date time.Time `gorm:"primaryKey;type:date" json:"date"` + UserId string `gorm:"primaryKey;type:varchar(255)" json:"userId"` + + UserEmail string `json:"userEmail" gorm:"type:varchar(255);index"` + + // Claude.ai (chat) metrics + ChatConversationCount int `json:"chatConversationCount"` + ChatMessageCount int `json:"chatMessageCount"` + ChatProjectsCreatedCount int `json:"chatProjectsCreatedCount"` + ChatProjectsUsedCount int `json:"chatProjectsUsedCount"` + ChatFilesUploadedCount int `json:"chatFilesUploadedCount"` + ChatArtifactsCreatedCount int `json:"chatArtifactsCreatedCount"` + ChatThinkingMessageCount int `json:"chatThinkingMessageCount"` + ChatSkillsUsedCount int `json:"chatSkillsUsedCount"` + ChatConnectorsUsedCount int `json:"chatConnectorsUsedCount"` + + // Claude Code core metrics + CCCommitCount int `json:"ccCommitCount"` + CCPullRequestCount int `json:"ccPullRequestCount"` + CCLinesAdded int `json:"ccLinesAdded"` + CCLinesRemoved int `json:"ccLinesRemoved"` + CCSessionCount int `json:"ccSessionCount"` + + // Claude Code tool actions + EditToolAccepted int `json:"editToolAccepted"` + EditToolRejected int `json:"editToolRejected"` + MultiEditToolAccepted int `json:"multiEditToolAccepted"` + MultiEditToolRejected int `json:"multiEditToolRejected"` + WriteToolAccepted int `json:"writeToolAccepted"` + WriteToolRejected int `json:"writeToolRejected"` + NotebookEditToolAccepted int `json:"notebookEditToolAccepted"` + NotebookEditToolRejected int `json:"notebookEditToolRejected"` + + WebSearchCount int `json:"webSearchCount"` + + common.NoPKModel +} + +func (ClaudeCodeUserActivity) TableName() string { + return "_tool_claude_code_user_activity" +} diff --git a/backend/plugins/claude_code/service/connection_test_helper.go b/backend/plugins/claude_code/service/connection_test_helper.go new file mode 100644 index 00000000000..6316072fe23 --- /dev/null +++ b/backend/plugins/claude_code/service/connection_test_helper.go @@ -0,0 +1,112 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + stdctx "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + corectx "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +// TestConnectionResult represents the payload returned by the connection test endpoints. +type TestConnectionResult struct { + Success bool `json:"success"` + Message string `json:"message"` + OrganizationId string `json:"organizationId,omitempty"` +} + +// TestConnection exercises the Claude Code Analytics API to validate credentials. +// It makes a minimal request with limit=1 to confirm the connection is valid. +func TestConnection(ctx stdctx.Context, br corectx.BasicRes, connection *models.ClaudeCodeConnection) (*TestConnectionResult, errors.Error) { + if connection == nil { + return nil, errors.BadInput.New("connection is required") + } + + connection.Normalize() + + hasToken := strings.TrimSpace(connection.Token) != "" + hasCustomHeaders := len(connection.CustomHeaders) > 0 + if !hasToken && !hasCustomHeaders { + return nil, errors.BadInput.New("either token or at least one custom header is required") + } + if strings.TrimSpace(connection.Organization) == "" { + return nil, errors.BadInput.New("organizationId is required") + } + + apiClient, err := helper.NewApiClientFromConnection(ctx, br, connection) + if err != nil { + return nil, err + } + + // Use today's date for the test request. + today := time.Now().UTC().Format("2006-01-02") + endpoint := fmt.Sprintf("v1/organizations/usage_report/claude_code?starting_at=%s&limit=1", today) + + res, err := apiClient.Get(endpoint, nil, nil) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to reach Claude Code Analytics API") + } + defer res.Body.Close() + + if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden { + return &TestConnectionResult{ + Success: false, + Message: fmt.Sprintf("authentication failed (HTTP %d): verify your API credentials", res.StatusCode), + }, nil + } + + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(res.Body) + return &TestConnectionResult{ + Success: false, + Message: fmt.Sprintf("unexpected status %d: %s", res.StatusCode, string(body)), + }, nil + } + + // Parse the response to confirm it's a valid analytics payload. + body, readErr := io.ReadAll(res.Body) + if readErr != nil { + return nil, errors.Default.Wrap(readErr, "failed to read response body") + } + + var response struct { + Data json.RawMessage `json:"data"` + HasMore bool `json:"has_more"` + } + if jsonErr := json.Unmarshal(body, &response); jsonErr != nil { + return &TestConnectionResult{ + Success: false, + Message: fmt.Sprintf("failed to parse response: %v", jsonErr), + }, nil + } + + return &TestConnectionResult{ + Success: true, + Message: "Connection validated successfully", + OrganizationId: connection.Organization, + }, nil +} diff --git a/backend/plugins/claude_code/tasks/activity_summary_collector.go b/backend/plugins/claude_code/tasks/activity_summary_collector.go new file mode 100644 index 00000000000..110c44e54b5 --- /dev/null +++ b/backend/plugins/claude_code/tasks/activity_summary_collector.go @@ -0,0 +1,97 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +// CollectActivitySummary collects daily organisation-level summaries from +// /v1/organizations/analytics/summaries in 31-day chunks. +func CollectActivitySummary(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData) + if !ok { + return errors.Default.New("task data is not ClaudeCodeTaskData") + } + connection := data.Connection + connection.Normalize() + + if strings.TrimSpace(connection.Organization) == "" { + taskCtx.GetLogger().Info("No organization configured, skipping activity summary collection") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawActivitySummaryTable, + Options: claudeCodeRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: "analytics/summaries", + }, + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + start, until := computeUsageDateRange(time.Now().UTC(), collector.GetSince()) + rangeIter := newClaudeCodeDateRangeIterator(start, until, claudeCodeSummaryMaxDays) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: rangeIter, + PageSize: 1, + Incremental: true, + UrlTemplate: "v1/organizations/analytics/summaries", + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*claudeCodeDateRangeInput) + query := url.Values{} + query.Set("starting_date", input.StartDate) + query.Set("ending_date", input.EndDate) + return query, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + page, err := parseClaudeCodeUsagePage(res) + if err != nil { + return nil, err + } + return page.Data, nil + }, + }) + if err != nil { + return err + } + + return collector.Execute() +} diff --git a/backend/plugins/claude_code/tasks/activity_summary_extractor.go b/backend/plugins/claude_code/tasks/activity_summary_extractor.go new file mode 100644 index 00000000000..8e63e8d8c2d --- /dev/null +++ b/backend/plugins/claude_code/tasks/activity_summary_extractor.go @@ -0,0 +1,99 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +// activitySummaryRecord is the JSON shape returned by /v1/organizations/analytics/summaries. +type activitySummaryRecord struct { + StartingDate string `json:"starting_date"` + EndingDate string `json:"ending_date"` + DailyActiveUserCount int `json:"daily_active_user_count"` + WeeklyActiveUserCount int `json:"weekly_active_user_count"` + MonthlyActiveUserCount int `json:"monthly_active_user_count"` + AssignedSeatCount int `json:"assigned_seat_count"` + PendingInviteCount int `json:"pending_invite_count"` +} + +// ExtractActivitySummary parses raw activity summary records into tool-layer tables. +func ExtractActivitySummary(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData) + if !ok { + return errors.Default.New("task data is not ClaudeCodeTaskData") + } + connection := data.Connection + connection.Normalize() + + if strings.TrimSpace(connection.Organization) == "" { + taskCtx.GetLogger().Info("No organization configured, skipping activity summary extraction") + return nil + } + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawActivitySummaryTable, + Options: claudeCodeRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: "analytics/summaries", + }, + }, + Extract: func(row *helper.RawData) ([]interface{}, errors.Error) { + var record activitySummaryRecord + if err := errors.Convert(json.Unmarshal(row.Data, &record)); err != nil { + return nil, err + } + + dateStr := strings.TrimSpace(record.StartingDate) + if dateStr == "" { + return nil, nil + } + t, parseErr := time.Parse("2006-01-02", dateStr) + if parseErr != nil { + return nil, errors.BadInput.Wrap(parseErr, "invalid starting_date in activity summary") + } + + summary := &models.ClaudeCodeActivitySummary{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Date: utcDate(t), + DailyActiveUserCount: record.DailyActiveUserCount, + WeeklyActiveUserCount: record.WeeklyActiveUserCount, + MonthlyActiveUserCount: record.MonthlyActiveUserCount, + AssignedSeatCount: record.AssignedSeatCount, + PendingInviteCount: record.PendingInviteCount, + } + return []interface{}{summary}, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} diff --git a/backend/plugins/claude_code/tasks/api_client.go b/backend/plugins/claude_code/tasks/api_client.go new file mode 100644 index 00000000000..8f3f29da4fb --- /dev/null +++ b/backend/plugins/claude_code/tasks/api_client.go @@ -0,0 +1,81 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "net/http" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/log" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +type nowFunc func() time.Time +type sleepFunc func(time.Duration) + +func handleAnthropicRetryAfter(res *http.Response, logger log.Logger, now nowFunc, sleep sleepFunc) errors.Error { + if res == nil { + return nil + } + if res.StatusCode != http.StatusTooManyRequests { + return nil + } + + if now == nil { + now = time.Now + } + if sleep == nil { + sleep = time.Sleep + } + + wait := parseRetryAfter(res.Header.Get("Retry-After"), now().UTC()) + if wait > 0 { + if logger != nil { + logger.Warn(nil, "Anthropic returned 429; sleeping %s per Retry-After", wait.String()) + } + sleep(wait) + } + return errors.HttpStatus(http.StatusTooManyRequests).New("Anthropic rate limited the request") +} + +// CreateApiClient creates an async API client for Claude Code collectors. +func CreateApiClient(taskCtx plugin.TaskContext, connection *models.ClaudeCodeConnection) (*helper.ApiAsyncClient, errors.Error) { + apiClient, err := helper.NewApiClientFromConnection(taskCtx.GetContext(), taskCtx, connection) + if err != nil { + return nil, err + } + + apiClient.SetHeaders(map[string]string{ + "Accept": "application/json", + }) + + rateLimiter := &helper.ApiRateLimitCalculator{UserRateLimitPerHour: connection.RateLimitPerHour} + asyncClient, err := helper.CreateAsyncApiClient(taskCtx, apiClient, rateLimiter) + if err != nil { + return nil, err + } + + apiClient.SetAfterFunction(func(res *http.Response) errors.Error { + return handleAnthropicRetryAfter(res, taskCtx.GetLogger(), time.Now, time.Sleep) + }) + + return asyncClient, nil +} diff --git a/backend/plugins/claude_code/tasks/chat_project_collector.go b/backend/plugins/claude_code/tasks/chat_project_collector.go new file mode 100644 index 00000000000..18062e186b3 --- /dev/null +++ b/backend/plugins/claude_code/tasks/chat_project_collector.go @@ -0,0 +1,102 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +// CollectChatProjects collects per-project daily usage from +// /v1/organizations/analytics/apps/chat/projects. +func CollectChatProjects(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData) + if !ok { + return errors.Default.New("task data is not ClaudeCodeTaskData") + } + connection := data.Connection + connection.Normalize() + + if strings.TrimSpace(connection.Organization) == "" { + taskCtx.GetLogger().Info("No organization configured, skipping chat project collection") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawChatProjectTable, + Options: claudeCodeRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: "analytics/apps/chat/projects", + }, + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + start, until := computeUsageDateRange(time.Now().UTC(), collector.GetSince()) + dayIter := newClaudeCodeDayIterator(start, until) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dayIter, + PageSize: 1, + Incremental: true, + UrlTemplate: "v1/organizations/analytics/apps/chat/projects", + GetNextPageCustomData: getNextClaudeCodePageCursor, + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*claudeCodeDayInput) + query := url.Values{} + 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) + } + return query, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + page, err := parseClaudeCodeUsagePage(res) + if err != nil { + return nil, err + } + return page.Data, nil + }, + }) + if err != nil { + return err + } + + return collector.Execute() +} diff --git a/backend/plugins/claude_code/tasks/chat_project_extractor.go b/backend/plugins/claude_code/tasks/chat_project_extractor.go new file mode 100644 index 00000000000..55a27a53c38 --- /dev/null +++ b/backend/plugins/claude_code/tasks/chat_project_extractor.go @@ -0,0 +1,115 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +// chatProjectRecord is the JSON shape returned by /v1/organizations/analytics/apps/chat/projects. +type chatProjectRecord struct { + ProjectName string `json:"project_name"` + ProjectId string `json:"project_id"` + DistinctUserCount int `json:"distinct_user_count"` + DistinctConversationCount int `json:"distinct_conversation_count"` + MessageCount int `json:"message_count"` + CreatedAt string `json:"created_at"` + CreatedBy chatProjectCreator `json:"created_by"` +} + +type chatProjectCreator struct { + Id string `json:"id"` + EmailAddress string `json:"email_address"` +} + +// ExtractChatProjects parses raw chat project records into tool-layer tables. +func ExtractChatProjects(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData) + if !ok { + return errors.Default.New("task data is not ClaudeCodeTaskData") + } + connection := data.Connection + connection.Normalize() + + if strings.TrimSpace(connection.Organization) == "" { + taskCtx.GetLogger().Info("No organization configured, skipping chat project extraction") + return nil + } + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawChatProjectTable, + Options: claudeCodeRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: "analytics/apps/chat/projects", + }, + }, + Extract: func(row *helper.RawData) ([]interface{}, errors.Error) { + var record chatProjectRecord + 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 + } + + projectId := strings.TrimSpace(record.ProjectId) + if projectId == "" { + return nil, nil + } + + var createdAt time.Time + if ts := strings.TrimSpace(record.CreatedAt); ts != "" { + if t, err := time.Parse(time.RFC3339, ts); err == nil { + createdAt = t.UTC() + } + } + + project := &models.ClaudeCodeChatProject{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Date: date, + ProjectId: projectId, + ProjectName: strings.TrimSpace(record.ProjectName), + DistinctUserCount: record.DistinctUserCount, + ConversationCount: record.DistinctConversationCount, + MessageCount: record.MessageCount, + CreatedAt: createdAt, + CreatedById: strings.TrimSpace(record.CreatedBy.Id), + CreatedByEmail: strings.TrimSpace(record.CreatedBy.EmailAddress), + } + return []interface{}{project}, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} diff --git a/backend/plugins/claude_code/tasks/collector_utils.go b/backend/plugins/claude_code/tasks/collector_utils.go new file mode 100644 index 00000000000..1be3a4165b3 --- /dev/null +++ b/backend/plugins/claude_code/tasks/collector_utils.go @@ -0,0 +1,195 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/errors" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +const ( + rawUserActivityTable = "claude_code_user_activity" + rawActivitySummaryTable = "claude_code_activity_summary" + rawChatProjectTable = "claude_code_chat_project" + rawSkillUsageTable = "claude_code_skill_usage" + rawConnectorUsageTable = "claude_code_connector_usage" + + claudeCodeApiPageLimit = 1000 + claudeCodeInitialBackfillDays = 90 + claudeCodeSummaryMaxDays = 31 + claudeCodeAvailabilityLagDays = 3 + claudeCodeDateLayout = "2006-01-02" +) + +// claudeCodeRawParams identifies a set of raw data records for a given connection/scope. +type claudeCodeRawParams struct { + ConnectionId uint64 + ScopeId string + Organization string + Endpoint string +} + +func (p claudeCodeRawParams) GetParams() any { + return p +} + +// claudeCodeUsagePage is the common paginated response envelope used by all analytics endpoints. +type claudeCodeUsagePage struct { + Data []json.RawMessage `json:"data"` + HasMore bool `json:"has_more"` + NextPage *string `json:"next_page"` +} + +// claudeCodeDayInput is the input item for day-based collectors. +type claudeCodeDayInput struct { + Day string `json:"day"` +} + +// claudeCodeDateRangeInput is the input item for the summaries (range-based) collector. +type claudeCodeDateRangeInput struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` +} + +// utcDate truncates a time to midnight UTC. +func utcDate(t time.Time) time.Time { + y, m, d := t.UTC().Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +// computeUsageDateRange returns the [start, until] date range for incremental collection. +// The Anthropic Analytics API only serves data from 2026-01-01 onwards and requires +// dates to be at least three days old, so the current day and previous two days are skipped. +func computeUsageDateRange(now time.Time, since *time.Time) (start, until time.Time) { + apiFloor := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + until = utcDate(now).AddDate(0, 0, -claudeCodeAvailabilityLagDays) + start = until.AddDate(0, 0, -(claudeCodeInitialBackfillDays - 1)) + if since != nil { + start = utcDate(*since).AddDate(0, 0, 1) + minStart := until.AddDate(0, 0, -(claudeCodeInitialBackfillDays - 1)) + if start.Before(minStart) { + start = minStart + } + } + if start.Before(apiFloor) { + start = apiFloor + } + return start, until +} + +// claudeCodeDayIterator iterates over individual calendar days. +type claudeCodeDayIterator struct { + days []claudeCodeDayInput + idx int +} + +func newClaudeCodeDayIterator(start, end time.Time) *claudeCodeDayIterator { + days := make([]claudeCodeDayInput, 0) + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + days = append(days, claudeCodeDayInput{Day: d.Format(claudeCodeDateLayout)}) + } + return &claudeCodeDayIterator{days: days} +} + +func (it *claudeCodeDayIterator) HasNext() bool { return it.idx < len(it.days) } +func (it *claudeCodeDayIterator) Close() errors.Error { return nil } +func (it *claudeCodeDayIterator) Fetch() (interface{}, errors.Error) { + if it.idx >= len(it.days) { + return nil, nil + } + day := it.days[it.idx] + it.idx++ + return &day, nil +} + +// claudeCodeDateRangeIterator iterates over date ranges in chunks of up to maxDays days. +type claudeCodeDateRangeIterator struct { + chunks []claudeCodeDateRangeInput + idx int +} + +func newClaudeCodeDateRangeIterator(start, end time.Time, maxDays int) *claudeCodeDateRangeIterator { + chunks := make([]claudeCodeDateRangeInput, 0) + for chunkStart := start; !chunkStart.After(end); { + chunkEnd := chunkStart.AddDate(0, 0, maxDays-1) + if chunkEnd.After(end) { + chunkEnd = end + } + chunks = append(chunks, claudeCodeDateRangeInput{ + StartDate: chunkStart.Format(claudeCodeDateLayout), + EndDate: chunkEnd.AddDate(0, 0, 1).Format(claudeCodeDateLayout), // exclusive end + }) + chunkStart = chunkEnd.AddDate(0, 0, 1) + } + return &claudeCodeDateRangeIterator{chunks: chunks} +} + +func (it *claudeCodeDateRangeIterator) HasNext() bool { return it.idx < len(it.chunks) } +func (it *claudeCodeDateRangeIterator) Close() errors.Error { return nil } +func (it *claudeCodeDateRangeIterator) Fetch() (interface{}, errors.Error) { + if it.idx >= len(it.chunks) { + return nil, nil + } + chunk := it.chunks[it.idx] + it.idx++ + return &chunk, nil +} + +// parseClaudeCodeUsagePage reads and parses a paginated analytics API response. +func parseClaudeCodeUsagePage(res *http.Response) (*claudeCodeUsagePage, errors.Error) { + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to read analytics response") + } + res.Body.Close() + + if res.StatusCode >= http.StatusBadRequest { + snippet := string(body) + if len(snippet) > 500 { + snippet = snippet[:500] + } + return nil, errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("analytics request failed: %s", snippet)) + } + + var page claudeCodeUsagePage + if err := errors.Convert(json.Unmarshal(body, &page)); err != nil { + return nil, errors.Default.Wrap(err, "failed to decode analytics response") + } + + res.Body = io.NopCloser(strings.NewReader(string(body))) + return &page, nil +} + +// getNextClaudeCodePageCursor returns the next page cursor or ErrFinishCollect. +func getNextClaudeCodePageCursor(_ *helper.RequestData, prevPageResponse *http.Response) (interface{}, errors.Error) { + page, err := parseClaudeCodeUsagePage(prevPageResponse) + if err != nil { + return nil, err + } + if !page.HasMore || page.NextPage == nil || strings.TrimSpace(*page.NextPage) == "" { + return nil, helper.ErrFinishCollect + } + return strings.TrimSpace(*page.NextPage), nil +} diff --git a/backend/plugins/claude_code/tasks/collector_utils_test.go b/backend/plugins/claude_code/tasks/collector_utils_test.go new file mode 100644 index 00000000000..86ba3165679 --- /dev/null +++ b/backend/plugins/claude_code/tasks/collector_utils_test.go @@ -0,0 +1,55 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestComputeUsageDateRangeSkipsRecentDays(t *testing.T) { + now := time.Date(2026, 6, 19, 14, 30, 0, 0, time.UTC) + + start, until := computeUsageDateRange(now, nil) + + assert.Equal(t, time.Date(2026, 3, 19, 0, 0, 0, 0, time.UTC), start) + assert.Equal(t, time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC), until) +} + +func TestComputeUsageDateRangeHonorsApiFloor(t *testing.T) { + now := time.Date(2026, 3, 19, 9, 0, 0, 0, time.UTC) + + start, until := computeUsageDateRange(now, nil) + + assert.Equal(t, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), start) + assert.Equal(t, time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC), until) +} + +func TestComputeUsageDateRangeProducesEmptyWindowInsideLag(t *testing.T) { + now := time.Date(2026, 6, 19, 14, 30, 0, 0, time.UTC) + since := time.Date(2026, 6, 16, 12, 0, 0, 0, time.UTC) + + start, until := computeUsageDateRange(now, &since) + + assert.Equal(t, time.Date(2026, 6, 17, 0, 0, 0, 0, time.UTC), start) + assert.Equal(t, time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC), until) + assert.False(t, newClaudeCodeDayIterator(start, until).HasNext()) + assert.False(t, newClaudeCodeDateRangeIterator(start, until, claudeCodeSummaryMaxDays).HasNext()) +} diff --git a/backend/plugins/claude_code/tasks/connector_usage_collector.go b/backend/plugins/claude_code/tasks/connector_usage_collector.go new file mode 100644 index 00000000000..076cf00e178 --- /dev/null +++ b/backend/plugins/claude_code/tasks/connector_usage_collector.go @@ -0,0 +1,101 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +// CollectConnectorUsage collects per-connector daily usage from /v1/organizations/analytics/connectors. +func CollectConnectorUsage(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData) + if !ok { + return errors.Default.New("task data is not ClaudeCodeTaskData") + } + connection := data.Connection + connection.Normalize() + + if strings.TrimSpace(connection.Organization) == "" { + taskCtx.GetLogger().Info("No organization configured, skipping connector usage collection") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawConnectorUsageTable, + Options: claudeCodeRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: "analytics/connectors", + }, + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + start, until := computeUsageDateRange(time.Now().UTC(), collector.GetSince()) + dayIter := newClaudeCodeDayIterator(start, until) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dayIter, + PageSize: 1, + Incremental: true, + UrlTemplate: "v1/organizations/analytics/connectors", + GetNextPageCustomData: getNextClaudeCodePageCursor, + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*claudeCodeDayInput) + query := url.Values{} + 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) + } + return query, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + page, err := parseClaudeCodeUsagePage(res) + if err != nil { + return nil, err + } + return page.Data, nil + }, + }) + if err != nil { + return err + } + + return collector.Execute() +} diff --git a/backend/plugins/claude_code/tasks/connector_usage_extractor.go b/backend/plugins/claude_code/tasks/connector_usage_extractor.go new file mode 100644 index 00000000000..833ca4619d0 --- /dev/null +++ b/backend/plugins/claude_code/tasks/connector_usage_extractor.go @@ -0,0 +1,103 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "strings" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +// connectorUsageRecord is the JSON shape returned by /v1/organizations/analytics/connectors. +type connectorUsageRecord struct { + ConnectorName string `json:"connector_name"` + DistinctUserCount int `json:"distinct_user_count"` + ChatMetrics connectorUsageChat `json:"chat_metrics"` + ClaudeCodeMetrics connectorUsageCC `json:"claude_code_metrics"` +} + +type connectorUsageChat struct { + DistinctConversationConnectorUsedCount int `json:"distinct_conversation_connector_used_count"` +} + +type connectorUsageCC struct { + DistinctSessionConnectorUsedCount int `json:"distinct_session_connector_used_count"` +} + +// ExtractConnectorUsage parses raw connector usage records into tool-layer tables. +func ExtractConnectorUsage(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData) + if !ok { + return errors.Default.New("task data is not ClaudeCodeTaskData") + } + connection := data.Connection + connection.Normalize() + + if strings.TrimSpace(connection.Organization) == "" { + taskCtx.GetLogger().Info("No organization configured, skipping connector usage extraction") + return nil + } + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawConnectorUsageTable, + Options: claudeCodeRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: "analytics/connectors", + }, + }, + Extract: func(row *helper.RawData) ([]interface{}, errors.Error) { + var record connectorUsageRecord + 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 + } + + connectorName := strings.TrimSpace(record.ConnectorName) + if connectorName == "" { + return nil, nil + } + + connector := &models.ClaudeCodeConnectorUsage{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Date: date, + ConnectorName: connectorName, + DistinctUserCount: record.DistinctUserCount, + ChatConversationCount: record.ChatMetrics.DistinctConversationConnectorUsedCount, + CCSessionCount: record.ClaudeCodeMetrics.DistinctSessionConnectorUsedCount, + } + return []interface{}{connector}, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} diff --git a/backend/plugins/claude_code/tasks/options.go b/backend/plugins/claude_code/tasks/options.go new file mode 100644 index 00000000000..f9f08f2e2db --- /dev/null +++ b/backend/plugins/claude_code/tasks/options.go @@ -0,0 +1,24 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// ClaudeCodeOptions defines task-level options passed from pipeline plans. +type ClaudeCodeOptions struct { + ConnectionId uint64 `mapstructure:"connectionId" json:"connectionId"` + ScopeId string `mapstructure:"scopeId" json:"scopeId"` +} diff --git a/backend/plugins/claude_code/tasks/register.go b/backend/plugins/claude_code/tasks/register.go new file mode 100644 index 00000000000..375076988a3 --- /dev/null +++ b/backend/plugins/claude_code/tasks/register.go @@ -0,0 +1,36 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import "github.com/apache/incubator-devlake/core/plugin" + +// GetSubTaskMetas returns the ordered list of Claude Code subtasks. +func GetSubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{ + CollectUserActivityMeta, + ExtractUserActivityMeta, + CollectActivitySummaryMeta, + ExtractActivitySummaryMeta, + CollectChatProjectsMeta, + ExtractChatProjectsMeta, + CollectSkillUsageMeta, + ExtractSkillUsageMeta, + CollectConnectorUsageMeta, + ExtractConnectorUsageMeta, + } +} diff --git a/backend/plugins/claude_code/tasks/retry_after.go b/backend/plugins/claude_code/tasks/retry_after.go new file mode 100644 index 00000000000..6b0a36b805e --- /dev/null +++ b/backend/plugins/claude_code/tasks/retry_after.go @@ -0,0 +1,44 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "net/http" + "strconv" + "time" +) + +func parseRetryAfter(value string, now time.Time) time.Duration { + if value == "" { + return 0 + } + if seconds, err := strconv.Atoi(value); err == nil { + if seconds <= 0 { + return 0 + } + return time.Duration(seconds) * time.Second + } + if t, err := http.ParseTime(value); err == nil { + wait := t.Sub(now) + if wait < 0 { + return 0 + } + return wait + } + return 0 +} diff --git a/backend/plugins/claude_code/tasks/skill_usage_collector.go b/backend/plugins/claude_code/tasks/skill_usage_collector.go new file mode 100644 index 00000000000..dfb87612da4 --- /dev/null +++ b/backend/plugins/claude_code/tasks/skill_usage_collector.go @@ -0,0 +1,101 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +// CollectSkillUsage collects per-skill daily usage from /v1/organizations/analytics/skills. +func CollectSkillUsage(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData) + if !ok { + return errors.Default.New("task data is not ClaudeCodeTaskData") + } + connection := data.Connection + connection.Normalize() + + if strings.TrimSpace(connection.Organization) == "" { + taskCtx.GetLogger().Info("No organization configured, skipping skill usage collection") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawSkillUsageTable, + Options: claudeCodeRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: "analytics/skills", + }, + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + start, until := computeUsageDateRange(time.Now().UTC(), collector.GetSince()) + dayIter := newClaudeCodeDayIterator(start, until) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dayIter, + PageSize: 1, + Incremental: true, + UrlTemplate: "v1/organizations/analytics/skills", + GetNextPageCustomData: getNextClaudeCodePageCursor, + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*claudeCodeDayInput) + query := url.Values{} + 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) + } + return query, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + page, err := parseClaudeCodeUsagePage(res) + if err != nil { + return nil, err + } + return page.Data, nil + }, + }) + if err != nil { + return err + } + + return collector.Execute() +} diff --git a/backend/plugins/claude_code/tasks/skill_usage_extractor.go b/backend/plugins/claude_code/tasks/skill_usage_extractor.go new file mode 100644 index 00000000000..72743cee395 --- /dev/null +++ b/backend/plugins/claude_code/tasks/skill_usage_extractor.go @@ -0,0 +1,103 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "strings" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +// skillUsageRecord is the JSON shape returned by /v1/organizations/analytics/skills. +type skillUsageRecord struct { + SkillName string `json:"skill_name"` + DistinctUserCount int `json:"distinct_user_count"` + ChatMetrics skillUsageChat `json:"chat_metrics"` + ClaudeCodeMetrics skillUsageCC `json:"claude_code_metrics"` +} + +type skillUsageChat struct { + DistinctConversationSkillUsedCount int `json:"distinct_conversation_skill_used_count"` +} + +type skillUsageCC struct { + DistinctSessionSkillUsedCount int `json:"distinct_session_skill_used_count"` +} + +// ExtractSkillUsage parses raw skill usage records into tool-layer tables. +func ExtractSkillUsage(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData) + if !ok { + return errors.Default.New("task data is not ClaudeCodeTaskData") + } + connection := data.Connection + connection.Normalize() + + if strings.TrimSpace(connection.Organization) == "" { + taskCtx.GetLogger().Info("No organization configured, skipping skill usage extraction") + return nil + } + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawSkillUsageTable, + Options: claudeCodeRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: "analytics/skills", + }, + }, + Extract: func(row *helper.RawData) ([]interface{}, errors.Error) { + var record skillUsageRecord + 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 + } + + skillName := strings.TrimSpace(record.SkillName) + if skillName == "" { + return nil, nil + } + + skill := &models.ClaudeCodeSkillUsage{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Date: date, + SkillName: skillName, + DistinctUserCount: record.DistinctUserCount, + ChatConversationCount: record.ChatMetrics.DistinctConversationSkillUsedCount, + CCSessionCount: record.ClaudeCodeMetrics.DistinctSessionSkillUsedCount, + } + return []interface{}{skill}, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} diff --git a/backend/plugins/claude_code/tasks/subtasks.go b/backend/plugins/claude_code/tasks/subtasks.go new file mode 100644 index 00000000000..82fd4ae63e0 --- /dev/null +++ b/backend/plugins/claude_code/tasks/subtasks.go @@ -0,0 +1,105 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import "github.com/apache/incubator-devlake/core/plugin" + +var CollectUserActivityMeta = plugin.SubTaskMeta{ + Name: "collectUserActivity", + EntryPoint: CollectUserActivity, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect per-user daily engagement metrics from the Anthropic Analytics API", +} + +var ExtractUserActivityMeta = plugin.SubTaskMeta{ + Name: "extractUserActivity", + EntryPoint: ExtractUserActivity, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract per-user daily engagement metrics into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectUserActivityMeta}, +} + +var CollectActivitySummaryMeta = plugin.SubTaskMeta{ + Name: "collectActivitySummary", + EntryPoint: CollectActivitySummary, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect organisation-level daily activity summaries from the Anthropic Analytics API", +} + +var ExtractActivitySummaryMeta = plugin.SubTaskMeta{ + Name: "extractActivitySummary", + EntryPoint: ExtractActivitySummary, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract organisation-level daily activity summaries into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectActivitySummaryMeta}, +} + +var CollectChatProjectsMeta = plugin.SubTaskMeta{ + Name: "collectChatProjects", + EntryPoint: CollectChatProjects, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect per-project daily chat usage from the Anthropic Analytics API", +} + +var ExtractChatProjectsMeta = plugin.SubTaskMeta{ + Name: "extractChatProjects", + EntryPoint: ExtractChatProjects, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract per-project daily chat usage into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectChatProjectsMeta}, +} + +var CollectSkillUsageMeta = plugin.SubTaskMeta{ + Name: "collectSkillUsage", + EntryPoint: CollectSkillUsage, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect per-skill daily usage from the Anthropic Analytics API", +} + +var ExtractSkillUsageMeta = plugin.SubTaskMeta{ + Name: "extractSkillUsage", + EntryPoint: ExtractSkillUsage, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract per-skill daily usage into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectSkillUsageMeta}, +} + +var CollectConnectorUsageMeta = plugin.SubTaskMeta{ + Name: "collectConnectorUsage", + EntryPoint: CollectConnectorUsage, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect per-connector daily usage from the Anthropic Analytics API", +} + +var ExtractConnectorUsageMeta = plugin.SubTaskMeta{ + Name: "extractConnectorUsage", + EntryPoint: ExtractConnectorUsage, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract per-connector daily usage into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectConnectorUsageMeta}, +} diff --git a/backend/plugins/claude_code/tasks/task_data.go b/backend/plugins/claude_code/tasks/task_data.go new file mode 100644 index 00000000000..7c72e5261b9 --- /dev/null +++ b/backend/plugins/claude_code/tasks/task_data.go @@ -0,0 +1,26 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import "github.com/apache/incubator-devlake/plugins/claude_code/models" + +// ClaudeCodeTaskData stores runtime dependencies for subtasks. +type ClaudeCodeTaskData struct { + Options *ClaudeCodeOptions + Connection *models.ClaudeCodeConnection +} diff --git a/backend/plugins/claude_code/tasks/user_activity_collector.go b/backend/plugins/claude_code/tasks/user_activity_collector.go new file mode 100644 index 00000000000..16a76472126 --- /dev/null +++ b/backend/plugins/claude_code/tasks/user_activity_collector.go @@ -0,0 +1,101 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +// CollectUserActivity collects per-user daily engagement metrics from /v1/organizations/analytics/users. +func CollectUserActivity(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData) + if !ok { + return errors.Default.New("task data is not ClaudeCodeTaskData") + } + connection := data.Connection + connection.Normalize() + + if strings.TrimSpace(connection.Organization) == "" { + taskCtx.GetLogger().Info("No organization configured, skipping user activity collection") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawUserActivityTable, + Options: claudeCodeRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: "analytics/users", + }, + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + start, until := computeUsageDateRange(time.Now().UTC(), collector.GetSince()) + dayIter := newClaudeCodeDayIterator(start, until) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dayIter, + PageSize: 1, + Incremental: true, + UrlTemplate: "v1/organizations/analytics/users", + GetNextPageCustomData: getNextClaudeCodePageCursor, + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*claudeCodeDayInput) + query := url.Values{} + 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) + } + return query, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + page, err := parseClaudeCodeUsagePage(res) + if err != nil { + return nil, err + } + return page.Data, nil + }, + }) + if err != nil { + return err + } + + return collector.Execute() +} diff --git a/backend/plugins/claude_code/tasks/user_activity_extractor.go b/backend/plugins/claude_code/tasks/user_activity_extractor.go new file mode 100644 index 00000000000..040b5a8d496 --- /dev/null +++ b/backend/plugins/claude_code/tasks/user_activity_extractor.go @@ -0,0 +1,192 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/claude_code/models" +) + +// userActivityRecord is the JSON shape returned by /v1/organizations/analytics/users. +type userActivityRecord struct { + User userActivityUser `json:"user"` + ChatMetrics userActivityChat `json:"chat_metrics"` + ClaudeCodeMetrics userActivityCC `json:"claude_code_metrics"` + WebSearchCount int `json:"web_search_count"` +} + +type userActivityUser struct { + Id string `json:"id"` + EmailAddress string `json:"email_address"` +} + +type userActivityChat struct { + DistinctConversationCount int `json:"distinct_conversation_count"` + MessageCount int `json:"message_count"` + DistinctProjectsCreatedCount int `json:"distinct_projects_created_count"` + DistinctProjectsUsedCount int `json:"distinct_projects_used_count"` + DistinctFilesUploadedCount int `json:"distinct_files_uploaded_count"` + DistinctArtifactsCreatedCount int `json:"distinct_artifacts_created_count"` + ThinkingMessageCount int `json:"thinking_message_count"` + DistinctSkillsUsedCount int `json:"distinct_skills_used_count"` + ConnectorsUsedCount int `json:"connectors_used_count"` +} + +type userActivityCC struct { + CoreMetrics userActivityCCCore `json:"core_metrics"` + ToolActions userActivityCCTools `json:"tool_actions"` +} + +type userActivityCCCore struct { + CommitCount int `json:"commit_count"` + PullRequestCount int `json:"pull_request_count"` + LinesOfCode userActivityLines `json:"lines_of_code"` + DistinctSessionCount int `json:"distinct_session_count"` +} + +type userActivityLines struct { + AddedCount int `json:"added_count"` + RemovedCount int `json:"removed_count"` +} + +type userActivityToolAction struct { + AcceptedCount int `json:"accepted_count"` + RejectedCount int `json:"rejected_count"` +} + +type userActivityCCTools struct { + EditTool userActivityToolAction `json:"edit_tool"` + MultiEditTool userActivityToolAction `json:"multi_edit_tool"` + WriteTool userActivityToolAction `json:"write_tool"` + NotebookEditTool userActivityToolAction `json:"notebook_edit_tool"` +} + +// ExtractUserActivity parses raw user activity records into tool-layer tables. +func ExtractUserActivity(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*ClaudeCodeTaskData) + if !ok { + return errors.Default.New("task data is not ClaudeCodeTaskData") + } + connection := data.Connection + connection.Normalize() + + if strings.TrimSpace(connection.Organization) == "" { + taskCtx.GetLogger().Info("No organization configured, skipping user activity extraction") + return nil + } + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawUserActivityTable, + Options: claudeCodeRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: "analytics/users", + }, + }, + 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, + } + return []interface{}{activity}, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} + +// 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)) + if parseErr == nil { + return utcDate(t), nil + } + } + return time.Time{}, errors.BadInput.New("could not parse date from raw input") +} diff --git a/backend/plugins/claude_code/tasks/user_activity_extractor_test.go b/backend/plugins/claude_code/tasks/user_activity_extractor_test.go new file mode 100644 index 00000000000..448ca252178 --- /dev/null +++ b/backend/plugins/claude_code/tasks/user_activity_extractor_test.go @@ -0,0 +1,46 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildUserActivityAllowedEmailSetNormalizesEmails(t *testing.T) { + allowedEmails := buildUserActivityAllowedEmailSet([]string{ + " Alice@example.com ", + "BOB@example.com", + "", + }) + + assert.Len(t, allowedEmails, 2) + _, hasAlice := allowedEmails["alice@example.com"] + _, hasBob := allowedEmails["bob@example.com"] + assert.True(t, hasAlice) + assert.True(t, hasBob) +} + +func TestShouldExtractUserActivityEmail(t *testing.T) { + allowedEmails := buildUserActivityAllowedEmailSet([]string{"alice@example.com"}) + + assert.True(t, shouldExtractUserActivityEmail(normalizeUserActivityEmail(" Alice@example.com "), allowedEmails)) + assert.False(t, shouldExtractUserActivityEmail(normalizeUserActivityEmail("bob@example.com"), allowedEmails)) + assert.False(t, shouldExtractUserActivityEmail("", allowedEmails)) +} diff --git a/config-ui/src/api/connection/index.ts b/config-ui/src/api/connection/index.ts index 5a1ff5f96aa..2728b103ecc 100644 --- a/config-ui/src/api/connection/index.ts +++ b/config-ui/src/api/connection/index.ts @@ -50,9 +50,13 @@ export const test = ( | 'appId' | 'secretKey' | 'proxy' + | 'rateLimitPerHour' | 'dbUrl' | 'companyId' + | 'adminApiKey' | 'organization' + | 'organizationId' + | 'customHeaders' > >, ): Promise => @@ -70,7 +74,11 @@ export const testOld = ( | 'appId' | 'secretKey' | 'proxy' + | 'rateLimitPerHour' | 'dbUrl' + | 'adminApiKey' | 'organization' + | 'organizationId' + | 'customHeaders' >, ): Promise => request(`/plugins/${plugin}/test`, { method: 'post', data: payload }); diff --git a/config-ui/src/config/entities.ts b/config-ui/src/config/entities.ts index c0ca9ccbb3c..edc2c125e51 100644 --- a/config-ui/src/config/entities.ts +++ b/config-ui/src/config/entities.ts @@ -22,6 +22,7 @@ export const EntitiesLabel: Record = { CODEREVIEW: 'Code Review', CICD: 'CI/CD', CROSS: 'Cross Domain', + CLAUDE_CODE: 'Claude Code', CODEQUALITY: 'Code Quality Domain', }; diff --git a/config-ui/src/features/connections/utils.ts b/config-ui/src/features/connections/utils.ts index 792213817ae..88cf1c692d7 100644 --- a/config-ui/src/features/connections/utils.ts +++ b/config-ui/src/features/connections/utils.ts @@ -23,23 +23,11 @@ import { IConnectionAPI, IConnection, IConnectionStatus, IWebhookAPI, IWebhook } export const transformConnection = (plugin: string, connection: IConnectionAPI): IConnection => { const config = getPluginConfig(plugin); return { + ...connection, unique: `${plugin}-${connection.id}`, plugin, pluginName: config.name, - id: connection.id, - name: connection.name, status: IConnectionStatus.IDLE, - endpoint: connection.endpoint, - authMethod: connection.authMethod, - token: connection.token, - username: connection.username, - password: connection.password, - appId: connection.appId, - secretKey: connection.secretKey, - dbUrl: connection.dbUrl, - proxy: connection.proxy, - rateLimitPerHour: connection.rateLimitPerHour, - organization: connection.organization, }; }; diff --git a/config-ui/src/plugins/components/connection-form/index.tsx b/config-ui/src/plugins/components/connection-form/index.tsx index 818f70d09e5..a60ace78011 100644 --- a/config-ui/src/plugins/components/connection-form/index.tsx +++ b/config-ui/src/plugins/components/connection-form/index.tsx @@ -71,9 +71,13 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => { appId: isEqual(connection?.appId, values.appId) ? undefined : values.appId, secretKey: isEqual(connection?.secretKey, values.secretKey) ? undefined : values.secretKey, proxy: isEqual(connection?.proxy, values.proxy) ? undefined : values.proxy, + rateLimitPerHour: isEqual(connection?.rateLimitPerHour, values.rateLimitPerHour) + ? undefined + : values.rateLimitPerHour, dbUrl: isEqual(connection?.dbUrl, values.dbUrl) ? undefined : values.dbUrl, companyId: isEqual(connection?.companyId, values.companyId) ? undefined : values.companyId, organization: isEqual(connection?.organization, values.organization) ? undefined : values.organization, + customHeaders: isEqual(connection?.customHeaders, values.customHeaders) ? undefined : values.customHeaders, }) : API.connection.testOld( plugin, @@ -99,6 +103,7 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => { 'dbUrl', 'companyId', 'organization', + 'customHeaders', ]), ), { diff --git a/config-ui/src/plugins/register/claude-code/assets/icon.svg b/config-ui/src/plugins/register/claude-code/assets/icon.svg new file mode 100644 index 00000000000..63abcb7c07d --- /dev/null +++ b/config-ui/src/plugins/register/claude-code/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/config-ui/src/plugins/register/claude-code/config.tsx b/config-ui/src/plugins/register/claude-code/config.tsx new file mode 100644 index 00000000000..78f5e737641 --- /dev/null +++ b/config-ui/src/plugins/register/claude-code/config.tsx @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg?react'; +import { Token, Organization, CustomHeaders } from './connection-fields'; + +export const ClaudeCodeConfig: IPluginConfig = { + plugin: 'claude_code', + name: 'Claude Code', + icon: ({ color }) => , + sort: 6.6, + isBeta: true, + connection: { + docLink: 'https://github.com/apache/incubator-devlake/tree/main/backend/plugins/claude_code', + initialValues: { + endpoint: 'https://api.anthropic.com', + organization: '', + token: '', + customHeaders: [], + rateLimitPerHour: 1000, + }, + fields: [ + 'name', + 'endpoint', + ({ type, initialValues, values, setValues, setErrors }: any) => ( + + ), + ({ type, initialValues, values, setValues, setErrors }: any) => ( + + ), + ({ type, initialValues, values, setValues, setErrors }: any) => ( + + ), + 'proxy', + { + key: 'rateLimitPerHour', + subLabel: + 'By default, DevLake uses 1,000 requests/hour for Claude Code usage collection. Adjust this value to throttle collection speed.', + defaultValue: 1000, + }, + ], + }, + dataScope: { + title: 'Organizations', + }, + scopeConfig: { + entities: ['CLAUDE_CODE'], + transformation: {}, + }, +}; diff --git a/config-ui/src/plugins/register/claude-code/connection-fields/custom-headers.tsx b/config-ui/src/plugins/register/claude-code/connection-fields/custom-headers.tsx new file mode 100644 index 00000000000..4763985059a --- /dev/null +++ b/config-ui/src/plugins/register/claude-code/connection-fields/custom-headers.tsx @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, type ChangeEvent } from 'react'; +import { Button, Input } from 'antd'; + +import { Block } from '@/components'; + +interface Props { + type: 'create' | 'update'; + initialValues: any; + values: any; + setValues: (value: any) => void; + setErrors: (value: any) => void; +} + +export const CustomHeaders = ({ type, initialValues, values, setValues }: Props) => { + const headers: Array<{ key: string; value: string }> = values.customHeaders ?? []; + + useEffect(() => { + setValues({ customHeaders: initialValues.customHeaders ?? [] }); + }, [type, initialValues.customHeaders]); + + const addHeader = () => { + setValues({ customHeaders: [...headers, { key: '', value: '' }] }); + }; + + const removeHeader = (index: number) => { + setValues({ customHeaders: headers.filter((_, i) => i !== index) }); + }; + + const updateHeader = (index: number, field: 'key' | 'value', newValue: string) => { + setValues({ + customHeaders: headers.map((h, i) => (i === index ? { ...h, [field]: newValue } : h)), + }); + }; + + return ( + + {headers.map((header, index) => ( +
+ ) => updateHeader(index, 'key', e.target.value)} + /> + ) => updateHeader(index, 'value', e.target.value)} + /> + +
+ ))} + +
+ ); +}; diff --git a/config-ui/src/plugins/register/claude-code/connection-fields/index.ts b/config-ui/src/plugins/register/claude-code/connection-fields/index.ts new file mode 100644 index 00000000000..0382f03be5d --- /dev/null +++ b/config-ui/src/plugins/register/claude-code/connection-fields/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './custom-headers'; +export * from './organization'; +export * from './token'; diff --git a/config-ui/src/plugins/register/claude-code/connection-fields/organization.tsx b/config-ui/src/plugins/register/claude-code/connection-fields/organization.tsx new file mode 100644 index 00000000000..0d9276a482a --- /dev/null +++ b/config-ui/src/plugins/register/claude-code/connection-fields/organization.tsx @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useMemo, type ChangeEvent } from 'react'; +import { Input } from 'antd'; + +import { Block } from '@/components'; + +import * as S from './styled'; + +interface Props { + type: 'create' | 'update'; + initialValues: any; + values: any; + setValues: (value: any) => void; + setErrors: (value: any) => void; +} + +export const Organization = ({ type, initialValues, values, setValues, setErrors }: Props) => { + useEffect(() => { + setValues({ organization: initialValues.organization ?? '' }); + }, [initialValues.organization]); + + const error = useMemo(() => { + return values.organization?.trim() ? '' : 'Anthropic Organization ID is required'; + }, [values.organization]); + + useEffect(() => { + setErrors({ organization: error }); + }, [error]); + + const handleChange = (e: ChangeEvent) => { + setValues({ organization: e.target.value }); + }; + + return ( + + + {error && {error}} + + ); +}; diff --git a/config-ui/src/plugins/register/claude-code/connection-fields/styled.ts b/config-ui/src/plugins/register/claude-code/connection-fields/styled.ts new file mode 100644 index 00000000000..82ba9f717d0 --- /dev/null +++ b/config-ui/src/plugins/register/claude-code/connection-fields/styled.ts @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import styled from 'styled-components'; + +export const ErrorText = styled.div` + margin-top: 4px; + color: #f5222d; + font-size: 12px; +`; diff --git a/config-ui/src/plugins/register/claude-code/connection-fields/token.tsx b/config-ui/src/plugins/register/claude-code/connection-fields/token.tsx new file mode 100644 index 00000000000..75ff54216ee --- /dev/null +++ b/config-ui/src/plugins/register/claude-code/connection-fields/token.tsx @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useMemo, type ChangeEvent } from 'react'; +import { Input } from 'antd'; + +import { Block } from '@/components'; + +import * as S from './styled'; + +interface Props { + type: 'create' | 'update'; + initialValues: any; + values: any; + setValues: (value: any) => void; + setErrors: (value: any) => void; +} + +export const Token = ({ type, initialValues, values, setValues, setErrors }: Props) => { + useEffect(() => { + setValues({ token: type === 'create' ? (initialValues.token ?? '') : undefined }); + }, [type, initialValues.token]); + + const hasCustomHeaders = (values.customHeaders ?? []).length > 0; + + const error = useMemo(() => { + if (type === 'update') return ''; + if (hasCustomHeaders) return ''; + return values.token?.trim() ? '' : 'Anthropic API Key is required (unless custom headers are configured)'; + }, [type, values.token, hasCustomHeaders]); + + useEffect(() => { + setErrors({ token: error }); + }, [error]); + + return ( + + ) => setValues({ token: e.target.value })} + /> + {error && {error}} + + ); +}; diff --git a/config-ui/src/plugins/register/claude-code/index.ts b/config-ui/src/plugins/register/claude-code/index.ts new file mode 100644 index 00000000000..de415db39ab --- /dev/null +++ b/config-ui/src/plugins/register/claude-code/index.ts @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './config'; diff --git a/config-ui/src/plugins/register/index.ts b/config-ui/src/plugins/register/index.ts index a58a60d7fd2..ae38894fb65 100644 --- a/config-ui/src/plugins/register/index.ts +++ b/config-ui/src/plugins/register/index.ts @@ -24,6 +24,7 @@ import { BambooConfig } from './bamboo'; import { BitbucketConfig } from './bitbucket'; import { BitbucketServerConfig } from './bitbucket-server'; import { CircleCIConfig } from './circleci'; +import { ClaudeCodeConfig } from './claude-code'; import { GitHubConfig } from './github'; import { GhCopilotConfig } from './gh-copilot'; import { GitLabConfig } from './gitlab'; @@ -48,6 +49,7 @@ export const pluginConfigs: IPluginConfig[] = [ BitbucketConfig, BitbucketServerConfig, CircleCIConfig, + ClaudeCodeConfig, GitHubConfig, GhCopilotConfig, GitLabConfig, diff --git a/config-ui/src/plugins/utils.ts b/config-ui/src/plugins/utils.ts index ea1b8d51487..e637fd78c7a 100644 --- a/config-ui/src/plugins/utils.ts +++ b/config-ui/src/plugins/utils.ts @@ -53,6 +53,14 @@ export const getPluginScopeName = (plugin: string, scope: any) => { return ''; } + if (plugin === 'claude_code') { + const scopeData = scope.data ?? scope; + + return `${ + scopeData.organization ?? scope.organization ?? scope.fullName ?? scope.name ?? scope.id ?? '' + }`.trim(); + } + if (plugin === 'gh-copilot') { const scopeData = scope.data ?? scope; @@ -79,6 +87,7 @@ export const getPluginScopeName = (plugin: string, scope: any) => { }; const pluginAliasMap: Record = { + claude_code: 'claude_code', copilot: 'gh-copilot', }; diff --git a/config-ui/src/types/connection.ts b/config-ui/src/types/connection.ts index 04551002ea8..17a3b85af0e 100644 --- a/config-ui/src/types/connection.ts +++ b/config-ui/src/types/connection.ts @@ -16,12 +16,18 @@ * */ +export interface ICustomHeader { + key: string; + value: string; +} + export interface IConnectionAPI { id: ID; name: string; endpoint: string; authMethod?: string; token?: string; + adminApiKey?: string; username?: string; password?: string; appId?: string; @@ -31,6 +37,8 @@ export interface IConnectionAPI { proxy: string; rateLimitPerHour?: number; organization?: string; + organizationId?: string; + customHeaders?: ICustomHeader[]; } export interface IConnectionTestResult { @@ -76,6 +84,7 @@ export interface IConnection { endpoint: string; authMethod?: string; token?: string; + adminApiKey?: string; username?: string; password?: string; appId?: string; @@ -85,4 +94,6 @@ export interface IConnection { proxy: string; rateLimitPerHour?: number; organization?: string; + organizationId?: string; + customHeaders?: ICustomHeader[]; } diff --git a/grafana/dashboards/ClaudeCodeAdoption.json b/grafana/dashboards/ClaudeCodeAdoption.json new file mode 100644 index 00000000000..82513cc1452 --- /dev/null +++ b/grafana/dashboards/ClaudeCodeAdoption.json @@ -0,0 +1,1295 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": "mysql", + "description": "Latest daily active user count from the organization summary feed", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT daily_active_user_count as \"Daily Active Users\"\nFROM _tool_claude_code_activity_summary\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY `date` DESC\nLIMIT 1", + "refId": "A" + } + ], + "title": "Daily Active Users", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Latest weekly active user count from the organization summary feed", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT weekly_active_user_count as \"Weekly Active Users\"\nFROM _tool_claude_code_activity_summary\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY `date` DESC\nLIMIT 1", + "refId": "A" + } + ], + "title": "Weekly Active Users", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Latest monthly active user count from the organization summary feed", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT monthly_active_user_count as \"Monthly Active Users\"\nFROM _tool_claude_code_activity_summary\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY `date` DESC\nLIMIT 1", + "refId": "A" + } + ], + "title": "Monthly Active Users", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Latest assigned seat count from the organization summary feed", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT assigned_seat_count as \"Assigned Seats\"\nFROM _tool_claude_code_activity_summary\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY `date` DESC\nLIMIT 1", + "refId": "A" + } + ], + "title": "Assigned Seats", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Latest pending invite count from the organization summary feed", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 4 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT pending_invite_count as \"Pending Invites\"\nFROM _tool_claude_code_activity_summary\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY `date` DESC\nLIMIT 1", + "refId": "A" + } + ], + "title": "Pending Invites", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Distinct Claude Code users with code activity on the most recent available analytics date", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 4 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(DISTINCT user_id) as \"Active Claude Code Users\"\nFROM _tool_claude_code_user_activity\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND `date` = (\n SELECT MAX(`date`)\n FROM _tool_claude_code_user_activity\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n )\n AND (\n cc_session_count > 0\n OR cc_commit_count > 0\n OR cc_pull_request_count > 0\n OR cc_lines_added > 0\n OR cc_lines_removed > 0\n OR edit_tool_accepted + edit_tool_rejected + multi_edit_tool_accepted + multi_edit_tool_rejected + write_tool_accepted + write_tool_rejected + notebook_edit_tool_accepted + notebook_edit_tool_rejected > 0\n )", + "refId": "A" + } + ], + "title": "Active Claude Code Users", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Total Claude Code sessions in the selected time range", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 4 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT SUM(cc_session_count) as \"Sessions\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'", + "refId": "A" + } + ], + "title": "Sessions", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Accepted tool actions as a percentage of all accepted and rejected tool actions", + "fieldConfig": { + "defaults": { + "mappings": [], + "min": 0, + "max": 100, + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 40 + }, + { + "color": "green", + "value": 70 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 4 + }, + "id": 8, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT ROUND(\n SUM(edit_tool_accepted + multi_edit_tool_accepted + write_tool_accepted + notebook_edit_tool_accepted) * 100.0\n / NULLIF(\n SUM(\n edit_tool_accepted + multi_edit_tool_accepted + write_tool_accepted + notebook_edit_tool_accepted\n + edit_tool_rejected + multi_edit_tool_rejected + write_tool_rejected + notebook_edit_tool_rejected\n ),\n 0\n ),\n 1\n) as \"Acceptance Rate %\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'", + "refId": "A" + } + ], + "title": "Tool Acceptance Rate", + "type": "gauge" + }, + { + "datasource": "mysql", + "description": "Total lines added by Claude Code across the organization in the selected time range", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 8 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT SUM(cc_lines_added) as \"Lines Added\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'", + "refId": "A" + } + ], + "title": "Lines Added", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Total commits attributed to Claude Code in the selected time range", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 8 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT SUM(cc_commit_count) as \"Commits\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'", + "refId": "A" + } + ], + "title": "Commits by Claude Code", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Total pull requests attributed to Claude Code in the selected time range", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 8 + }, + "id": 11, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT SUM(cc_pull_request_count) as \"PRs\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'", + "refId": "A" + } + ], + "title": "PRs by Claude Code", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "How many days have elapsed since the newest row across the migrated Claude analytics tables; Anthropic analytics normally lag by about 3 days", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 4 + }, + { + "color": "red", + "value": 8 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 8 + }, + "id": 12, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(TIMESTAMPDIFF(DAY, MAX(latest_date), UTC_DATE()), 9999) as \"Days Since Latest Data\"\nFROM (\n SELECT MAX(`date`) as latest_date\n FROM _tool_claude_code_user_activity\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT MAX(`date`) as latest_date\n FROM _tool_claude_code_activity_summary\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT MAX(`date`) as latest_date\n FROM _tool_claude_code_chat_project\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT MAX(`date`) as latest_date\n FROM _tool_claude_code_skill_usage\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT MAX(`date`) as latest_date\n FROM _tool_claude_code_connector_usage\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n) latest_dates", + "refId": "A" + } + ], + "title": "Data Freshness", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Daily, weekly, and monthly active user counts over time from the organization summary feed", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 12 + }, + "id": 13, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT `date` as time,\n daily_active_user_count as \"Daily Active Users\",\n weekly_active_user_count as \"Weekly Active Users\",\n monthly_active_user_count as \"Monthly Active Users\"\nFROM _tool_claude_code_activity_summary\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY 1", + "refId": "A" + } + ], + "title": "Org Active Users Over Time", + "type": "timeseries" + }, + { + "datasource": "mysql", + "description": "Daily Claude Code sessions and distinct users with code activity over time", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 12 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT `date` as time,\n SUM(cc_session_count) as \"Sessions\",\n COUNT(DISTINCT CASE\n WHEN (\n cc_session_count > 0\n OR cc_commit_count > 0\n OR cc_pull_request_count > 0\n OR cc_lines_added > 0\n OR cc_lines_removed > 0\n OR edit_tool_accepted + edit_tool_rejected + multi_edit_tool_accepted + multi_edit_tool_rejected + write_tool_accepted + write_tool_rejected + notebook_edit_tool_accepted + notebook_edit_tool_rejected > 0\n ) THEN user_id\n ELSE NULL\n END) as \"Active Users\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nGROUP BY `date`\nORDER BY 1", + "refId": "A" + } + ], + "title": "Sessions and Active Claude Code Users Over Time", + "type": "timeseries" + }, + { + "datasource": "mysql", + "description": "Lines added and removed over time", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 20 + }, + "id": 15, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT `date` as time,\n SUM(cc_lines_added) as \"Lines Added\",\n SUM(cc_lines_removed) as \"Lines Removed\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nGROUP BY `date`\nORDER BY 1", + "refId": "A" + } + ], + "title": "Code Churn Over Time", + "type": "timeseries" + }, + { + "datasource": "mysql", + "description": "Assigned seats and pending invites over time from the organization summary feed", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 20 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT `date` as time,\n assigned_seat_count as \"Assigned Seats\",\n pending_invite_count as \"Pending Invites\"\nFROM _tool_claude_code_activity_summary\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY 1", + "refId": "A" + } + ], + "title": "Seats and Pending Invites Over Time", + "type": "timeseries" + }, + { + "datasource": "mysql", + "description": "Accepted and rejected tool actions, grouped by Claude Code tool", + "fieldConfig": { + "defaults": { + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 28 + }, + "id": 17, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT tool_name as \"Tool\",\n SUM(accepted) as \"Accepted\",\n SUM(rejected) as \"Rejected\",\n ROUND(SUM(accepted) * 100.0 / NULLIF(SUM(accepted + rejected), 0), 1) as \"Acceptance Rate %\"\nFROM (\n SELECT `date`, connection_id, scope_id, 'edit_tool' as tool_name, edit_tool_accepted as accepted, edit_tool_rejected as rejected\n FROM _tool_claude_code_user_activity\n UNION ALL\n SELECT `date`, connection_id, scope_id, 'multi_edit_tool' as tool_name, multi_edit_tool_accepted as accepted, multi_edit_tool_rejected as rejected\n FROM _tool_claude_code_user_activity\n UNION ALL\n SELECT `date`, connection_id, scope_id, 'write_tool' as tool_name, write_tool_accepted as accepted, write_tool_rejected as rejected\n FROM _tool_claude_code_user_activity\n UNION ALL\n SELECT `date`, connection_id, scope_id, 'notebook_edit_tool' as tool_name, notebook_edit_tool_accepted as accepted, notebook_edit_tool_rejected as rejected\n FROM _tool_claude_code_user_activity\n) tool_actions\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nGROUP BY tool_name\nORDER BY SUM(accepted + rejected) DESC, tool_name", + "refId": "A" + } + ], + "title": "Tool Acceptance by Tool", + "type": "table" + }, + { + "datasource": "mysql", + "description": "Peak daily users and session counts grouped by Claude skill usage", + "fieldConfig": { + "defaults": { + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 28 + }, + "id": 18, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT skill_name as \"Skill\",\n MAX(distinct_user_count) as \"Peak Daily Users\",\n SUM(chat_conversation_count) as \"Chat Conversations\",\n SUM(cc_session_count) as \"Claude Code Sessions\"\nFROM _tool_claude_code_skill_usage\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nGROUP BY skill_name\nORDER BY SUM(cc_session_count + chat_conversation_count) DESC, skill_name", + "refId": "A" + } + ], + "title": "Skill Usage by Skill", + "type": "table" + }, + { + "datasource": "mysql", + "description": "Peak daily users and session counts grouped by connector usage", + "fieldConfig": { + "defaults": { + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 36 + }, + "id": 19, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT connector_name as \"Connector\",\n MAX(distinct_user_count) as \"Peak Daily Users\",\n SUM(chat_conversation_count) as \"Chat Conversations\",\n SUM(cc_session_count) as \"Claude Code Sessions\"\nFROM _tool_claude_code_connector_usage\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nGROUP BY connector_name\nORDER BY SUM(cc_session_count + chat_conversation_count) DESC, connector_name", + "refId": "A" + } + ], + "title": "Connector Usage by Connector", + "type": "table" + }, + { + "datasource": "mysql", + "description": "Project-level chat adoption with only project aggregates; user identifiers are intentionally excluded from this org dashboard", + "fieldConfig": { + "defaults": { + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 36 + }, + "id": 20, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(NULLIF(project_name, ''), project_id) as \"Project\",\n MAX(distinct_user_count) as \"Peak Daily Users\",\n SUM(conversation_count) as \"Conversations\",\n SUM(message_count) as \"Messages\"\nFROM _tool_claude_code_chat_project\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nGROUP BY COALESCE(NULLIF(project_name, ''), project_id)\nORDER BY SUM(message_count + conversation_count) DESC, 1\nLIMIT 25", + "refId": "A" + } + ], + "title": "Chat Project Usage", + "type": "table" + }, + { + "datasource": "mysql", + "description": "Diagnostics table for row counts and freshest available date per Claude metric table", + "fieldConfig": { + "defaults": { + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 44 + }, + "id": 21, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT *\nFROM (\n SELECT '_tool_claude_code_activity_summary' as table_name,\n COUNT(*) as row_count,\n MAX(`date`) as latest_date,\n COALESCE(TIMESTAMPDIFF(DAY, MAX(`date`), UTC_DATE()), 9999) as days_lag\n FROM _tool_claude_code_activity_summary\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_claude_code_user_activity' as table_name,\n COUNT(*) as row_count,\n MAX(`date`) as latest_date,\n COALESCE(TIMESTAMPDIFF(DAY, MAX(`date`), UTC_DATE()), 9999) as days_lag\n FROM _tool_claude_code_user_activity\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_claude_code_skill_usage' as table_name,\n COUNT(*) as row_count,\n MAX(`date`) as latest_date,\n COALESCE(TIMESTAMPDIFF(DAY, MAX(`date`), UTC_DATE()), 9999) as days_lag\n FROM _tool_claude_code_skill_usage\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_claude_code_connector_usage' as table_name,\n COUNT(*) as row_count,\n MAX(`date`) as latest_date,\n COALESCE(TIMESTAMPDIFF(DAY, MAX(`date`), UTC_DATE()), 9999) as days_lag\n FROM _tool_claude_code_connector_usage\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_claude_code_chat_project' as table_name,\n COUNT(*) as row_count,\n MAX(`date`) as latest_date,\n COALESCE(TIMESTAMPDIFF(DAY, MAX(`date`), UTC_DATE()), 9999) as days_lag\n FROM _tool_claude_code_chat_project\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n) data_volume\nORDER BY row_count DESC, table_name", + "refId": "A" + } + ], + "title": "Data Volume by Table", + "type": "table" + } + ], + "refresh": "", + "schemaVersion": 38, + "tags": [ + "claude-code", + "devlake", + "org" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "", + "value": "" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT connection_id FROM _tool_claude_code_scopes ORDER BY connection_id DESC", + "hide": 0, + "includeAll": false, + "label": "Connection ID", + "multi": false, + "name": "connection_id", + "options": [], + "query": "SELECT DISTINCT connection_id FROM _tool_claude_code_scopes ORDER BY connection_id DESC", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": { + "selected": false, + "text": "", + "value": "" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT id as scope_id FROM _tool_claude_code_scopes WHERE connection_id = ${connection_id} ORDER BY 1", + "hide": 0, + "includeAll": false, + "label": "Scope ID", + "multi": false, + "name": "scope_id", + "options": [], + "query": "SELECT DISTINCT id as scope_id FROM _tool_claude_code_scopes WHERE connection_id = ${connection_id} ORDER BY 1", + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-90d", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "Claude Code Adoption Overview", + "uid": "claude_code_adoption", + "version": 3, + "weekStart": "" +} diff --git a/grafana/dashboards/ClaudeCodeAdoptionByUser.json b/grafana/dashboards/ClaudeCodeAdoptionByUser.json new file mode 100644 index 00000000000..48af63c3376 --- /dev/null +++ b/grafana/dashboards/ClaudeCodeAdoptionByUser.json @@ -0,0 +1,1301 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": "mysql", + "description": "Org-level daily active user count; intentionally ignores the Selected User filter", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT daily_active_user_count as \"Org Daily Active Users\"\nFROM _tool_claude_code_activity_summary\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY `date` DESC\nLIMIT 1", + "refId": "A" + } + ], + "title": "Org Daily Active Users", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Org-level assigned seat count; intentionally ignores the Selected User filter", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT assigned_seat_count as \"Org Assigned Seats\"\nFROM _tool_claude_code_activity_summary\nWHERE connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY `date` DESC\nLIMIT 1", + "refId": "A" + } + ], + "title": "Org Assigned Seats", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Org-level session total for the selected time range; intentionally ignores the Selected User filter", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(SUM(cc_session_count), 0) as \"Org Sessions\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'", + "refId": "A" + } + ], + "title": "Org Sessions", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Days since the freshest Claude analytics row across the registered metric tables; intentionally ignores the Selected User filter", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 4 + }, + { + "color": "red", + "value": 8 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(TIMESTAMPDIFF(DAY, MAX(latest_date), UTC_DATE()), 9999) as \"Days Since Latest Data\"\nFROM (\n SELECT MAX(`date`) as latest_date\n FROM _tool_claude_code_user_activity\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT MAX(`date`) as latest_date\n FROM _tool_claude_code_activity_summary\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT MAX(`date`) as latest_date\n FROM _tool_claude_code_chat_project\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT MAX(`date`) as latest_date\n FROM _tool_claude_code_skill_usage\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT MAX(`date`) as latest_date\n FROM _tool_claude_code_connector_usage\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n) latest_dates", + "refId": "A" + } + ], + "title": "Data Freshness", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 4 + }, + "id": 5, + "panels": [], + "title": "Selected User Metrics", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Sessions for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 5 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(SUM(cc_session_count), 0) as \"Selected User Sessions\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )", + "refId": "A" + } + ], + "title": "Selected User Sessions", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Tool acceptance rate for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "mappings": [], + "min": 0, + "max": 100, + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 40 + }, + { + "color": "green", + "value": 70 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 5 + }, + "id": 7, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT ROUND(\n SUM(edit_tool_accepted + multi_edit_tool_accepted + write_tool_accepted + notebook_edit_tool_accepted) * 100.0\n / NULLIF(\n SUM(\n edit_tool_accepted + multi_edit_tool_accepted + write_tool_accepted + notebook_edit_tool_accepted\n + edit_tool_rejected + multi_edit_tool_rejected + write_tool_rejected + notebook_edit_tool_rejected\n ),\n 0\n ),\n 1\n) as \"Selected User Acceptance Rate %\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )", + "refId": "A" + } + ], + "title": "Selected User Tool Acceptance Rate", + "type": "gauge" + }, + { + "datasource": "mysql", + "description": "Lines added for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 5 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(SUM(cc_lines_added), 0) as \"Selected User Lines Added\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )", + "refId": "A" + } + ], + "title": "Selected User Lines Added", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Lines removed for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 5 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(SUM(cc_lines_removed), 0) as \"Selected User Lines Removed\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )", + "refId": "A" + } + ], + "title": "Selected User Lines Removed", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Commits for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 9 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(SUM(cc_commit_count), 0) as \"Selected User Commits\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )", + "refId": "A" + } + ], + "title": "Selected User Commits", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Pull requests for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 9 + }, + "id": 11, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(SUM(cc_pull_request_count), 0) as \"Selected User PRs\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )", + "refId": "A" + } + ], + "title": "Selected User PRs", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Days with recorded Claude activity for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 9 + }, + "id": 12, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(DISTINCT CASE\n WHEN (\n cc_session_count > 0\n OR cc_commit_count > 0\n OR cc_pull_request_count > 0\n OR cc_lines_added > 0\n OR cc_lines_removed > 0\n OR chat_conversation_count > 0\n OR web_search_count > 0\n OR edit_tool_accepted + edit_tool_rejected + multi_edit_tool_accepted + multi_edit_tool_rejected + write_tool_accepted + write_tool_rejected + notebook_edit_tool_accepted + notebook_edit_tool_rejected > 0\n ) THEN `date`\n ELSE NULL\n END) as \"Selected User Active Days\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )", + "refId": "A" + } + ], + "title": "Selected User Active Days", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Chat conversations for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 9 + }, + "id": 13, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COALESCE(SUM(chat_conversation_count), 0) as \"Selected User Chat Conversations\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )", + "refId": "A" + } + ], + "title": "Selected User Chat Conversations", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 14, + "panels": [], + "title": "Selected User Trends", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Sessions, commits, pull requests, and chat conversations over time for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 15, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT `date` as time,\n SUM(cc_session_count) as \"Sessions\",\n SUM(cc_commit_count) as \"Commits\",\n SUM(cc_pull_request_count) as \"PRs\",\n SUM(chat_conversation_count) as \"Chat Conversations\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )\nGROUP BY `date`\nORDER BY 1", + "refId": "A" + } + ], + "title": "Selected User Activity Over Time", + "type": "timeseries" + }, + { + "datasource": "mysql", + "description": "Code churn over time for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT `date` as time,\n SUM(cc_lines_added) as \"Lines Added\",\n SUM(cc_lines_removed) as \"Lines Removed\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )\nGROUP BY `date`\nORDER BY 1", + "refId": "A" + } + ], + "title": "Selected User Code Churn Over Time", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 17, + "panels": [], + "title": "Selected User Breakdowns", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Accepted and rejected tool actions for the selected user; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 23 + }, + "id": 18, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT tool_name as \"Tool\",\n SUM(accepted) as \"Accepted\",\n SUM(rejected) as \"Rejected\",\n ROUND(SUM(accepted) * 100.0 / NULLIF(SUM(accepted + rejected), 0), 1) as \"Acceptance Rate %\"\nFROM (\n SELECT `date`, connection_id, scope_id, 'edit_tool' as tool_name, edit_tool_accepted as accepted, edit_tool_rejected as rejected, user_id, user_email\n FROM _tool_claude_code_user_activity\n UNION ALL\n SELECT `date`, connection_id, scope_id, 'multi_edit_tool' as tool_name, multi_edit_tool_accepted as accepted, multi_edit_tool_rejected as rejected, user_id, user_email\n FROM _tool_claude_code_user_activity\n UNION ALL\n SELECT `date`, connection_id, scope_id, 'write_tool' as tool_name, write_tool_accepted as accepted, write_tool_rejected as rejected, user_id, user_email\n FROM _tool_claude_code_user_activity\n UNION ALL\n SELECT `date`, connection_id, scope_id, 'notebook_edit_tool' as tool_name, notebook_edit_tool_accepted as accepted, notebook_edit_tool_rejected as rejected, user_id, user_email\n FROM _tool_claude_code_user_activity\n) tool_actions\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )\nGROUP BY tool_name\nORDER BY SUM(accepted + rejected) DESC, tool_name", + "refId": "A" + } + ], + "title": "Selected User Tool Acceptance by Tool", + "type": "table" + }, + { + "datasource": "mysql", + "description": "Daily activity details for the selected user without exposing user identity columns; admins and managers can inspect any user, other viewers are restricted to their own email-matched data", + "fieldConfig": { + "defaults": { + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 23 + }, + "id": 19, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT `date` as \"Day\",\n SUM(cc_session_count) as \"Sessions\",\n SUM(chat_conversation_count) as \"Chat Conversations\",\n SUM(web_search_count) as \"Web Searches\",\n SUM(cc_lines_added) as \"Lines Added\",\n SUM(cc_lines_removed) as \"Lines Removed\",\n SUM(cc_commit_count) as \"Commits\",\n SUM(cc_pull_request_count) as \"PRs\",\n ROUND(\n SUM(edit_tool_accepted + multi_edit_tool_accepted + write_tool_accepted + notebook_edit_tool_accepted) * 100.0\n / NULLIF(\n SUM(\n edit_tool_accepted + multi_edit_tool_accepted + write_tool_accepted + notebook_edit_tool_accepted\n + edit_tool_rejected + multi_edit_tool_rejected + write_tool_rejected + notebook_edit_tool_rejected\n ),\n 0\n ),\n 1\n ) as \"Tool Acceptance Rate %\"\nFROM _tool_claude_code_user_activity\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\n AND (\n ixd_get_user_role('${__user.email}') IN ('admin', 'manager')\n OR user_email = '${__user.email}'\n )\n AND (\n '${user:csv}' = '0'\n OR COALESCE(NULLIF(user_email, ''), user_id) = ${user:sqlstring}\n )\nGROUP BY `date`\nORDER BY `date` DESC\nLIMIT 60", + "refId": "A" + } + ], + "title": "Selected User Daily Activity", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 31 + }, + "id": 20, + "panels": [], + "title": "Org Context", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Org-level active user counts over time; intentionally ignores the Selected User filter", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 21, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT `date` as time,\n daily_active_user_count as \"Daily Active Users\",\n weekly_active_user_count as \"Weekly Active Users\",\n monthly_active_user_count as \"Monthly Active Users\"\nFROM _tool_claude_code_activity_summary\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY 1", + "refId": "A" + } + ], + "title": "Org Active Users Over Time", + "type": "timeseries" + }, + { + "datasource": "mysql", + "description": "Org-level assigned seats and pending invites over time; intentionally ignores the Selected User filter", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 22, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT `date` as time,\n assigned_seat_count as \"Assigned Seats\",\n pending_invite_count as \"Pending Invites\"\nFROM _tool_claude_code_activity_summary\nWHERE $__timeFilter(`date`)\n AND connection_id = ${connection_id}\n AND scope_id = '${scope_id}'\nORDER BY 1", + "refId": "A" + } + ], + "title": "Org Seats and Pending Invites Over Time", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 40 + }, + "id": 23, + "panels": [], + "title": "Diagnostics", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Diagnostics table for row counts and freshest available date per Claude metric table; intentionally ignores the Selected User filter", + "fieldConfig": { + "defaults": { + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 41 + }, + "id": 24, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT *\nFROM (\n SELECT '_tool_claude_code_activity_summary' as table_name,\n COUNT(*) as row_count,\n MAX(`date`) as latest_date,\n COALESCE(TIMESTAMPDIFF(DAY, MAX(`date`), UTC_DATE()), 9999) as days_lag\n FROM _tool_claude_code_activity_summary\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_claude_code_user_activity' as table_name,\n COUNT(*) as row_count,\n MAX(`date`) as latest_date,\n COALESCE(TIMESTAMPDIFF(DAY, MAX(`date`), UTC_DATE()), 9999) as days_lag\n FROM _tool_claude_code_user_activity\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_claude_code_skill_usage' as table_name,\n COUNT(*) as row_count,\n MAX(`date`) as latest_date,\n COALESCE(TIMESTAMPDIFF(DAY, MAX(`date`), UTC_DATE()), 9999) as days_lag\n FROM _tool_claude_code_skill_usage\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_claude_code_connector_usage' as table_name,\n COUNT(*) as row_count,\n MAX(`date`) as latest_date,\n COALESCE(TIMESTAMPDIFF(DAY, MAX(`date`), UTC_DATE()), 9999) as days_lag\n FROM _tool_claude_code_connector_usage\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_claude_code_chat_project' as table_name,\n COUNT(*) as row_count,\n MAX(`date`) as latest_date,\n COALESCE(TIMESTAMPDIFF(DAY, MAX(`date`), UTC_DATE()), 9999) as days_lag\n FROM _tool_claude_code_chat_project\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n) data_volume\nORDER BY row_count DESC, table_name", + "refId": "A" + } + ], + "title": "Data Volume by Table", + "type": "table" + } + ], + "refresh": "", + "schemaVersion": 38, + "tags": [ + "claude-code", + "devlake", + "user", + "org" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "", + "value": "" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT connection_id FROM _tool_claude_code_scopes ORDER BY connection_id DESC", + "hide": 0, + "includeAll": false, + "label": "Connection ID", + "multi": false, + "name": "connection_id", + "options": [], + "query": "SELECT DISTINCT connection_id FROM _tool_claude_code_scopes ORDER BY connection_id DESC", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": { + "selected": false, + "text": "", + "value": "" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT id as scope_id FROM _tool_claude_code_scopes WHERE connection_id = ${connection_id} ORDER BY 1", + "hide": 0, + "includeAll": false, + "label": "Scope ID", + "multi": false, + "name": "scope_id", + "options": [], + "query": "SELECT DISTINCT id as scope_id FROM _tool_claude_code_scopes WHERE connection_id = ${connection_id} ORDER BY 1", + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "allValue": "0", + "current": { + "selected": true, + "text": "All Accessible Users", + "value": "0" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT user_key as __value, user_key as __text FROM (SELECT COALESCE(NULLIF(user_email, ''), user_id) as user_key, user_email FROM _tool_claude_code_user_activity WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}') accessible_users WHERE ixd_get_user_role('${__user.email}') IN ('admin', 'manager') OR user_email = '${__user.email}' ORDER BY user_key", + "hide": 0, + "includeAll": true, + "label": "Selected User", + "multi": false, + "name": "user", + "options": [], + "query": "SELECT DISTINCT user_key as __value, user_key as __text FROM (SELECT COALESCE(NULLIF(user_email, ''), user_id) as user_key, user_email FROM _tool_claude_code_user_activity WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}') accessible_users WHERE ixd_get_user_role('${__user.email}') IN ('admin', 'manager') OR user_email = '${__user.email}' ORDER BY user_key", + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-90d", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "Claude Code Adoption by User", + "uid": "claude_code_adoption_by_user", + "version": 2, + "weekStart": "" +} \ No newline at end of file