This is an automated email from the ASF dual-hosted git repository.

warren pushed a commit to branch feat/q-dev-account-id-scope-redesign
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git

commit 0a5a645d41b3ed9192870640c90be53b6faab340
Author: warren <[email protected]>
AuthorDate: Fri Feb 20 15:48:10 2026 +0800

    feat(q_dev): add Account ID to scope config and auto-construct both report 
paths
    
    User now provides basePath and accountId instead of manually typing the full
    S3 prefix. The collector automatically constructs and scans both
    by_user_analytic and user_report paths under
    {basePath}/AWSLogs/{accountId}/KiroLogs/…/{region}/{year}[/{month}].
    
    Includes migration, updated blueprint, multi-prefix collector iteration,
    user_report model/extractor/dashboard, and frontend Account ID input field.
    Old scopes without accountId continue to work unchanged.
---
 backend/plugins/q_dev/api/blueprint_v200.go        |   4 +
 backend/plugins/q_dev/impl/impl.go                 |  21 +
 backend/plugins/q_dev/impl/impl_test.go            |  39 +-
 ...gister.go => 20260219_add_user_report_table.go} |  31 +-
 ...r.go => 20260220_add_account_id_to_s3_slice.go} |  36 +-
 .../migrationscripts/archived/user_report.go       |  46 ++
 .../q_dev/models/migrationscripts/register.go      |   2 +
 backend/plugins/q_dev/models/s3_slice.go           |  58 ++-
 backend/plugins/q_dev/models/user_report.go        |  46 ++
 backend/plugins/q_dev/tasks/s3_data_extractor.go   | 139 +++++-
 .../plugins/q_dev/tasks/s3_data_extractor_test.go  | 167 ++++++++
 backend/plugins/q_dev/tasks/s3_file_collector.go   | 108 +++--
 backend/plugins/q_dev/tasks/task_data.go           |   7 +-
 backend/plugins/q_dev/tasks/task_data_test.go      |  16 +
 .../src/plugins/register/q-dev/data-scope.tsx      |  87 +++-
 grafana/dashboards/qdev_user_data.json             |   2 +-
 grafana/dashboards/qdev_user_report.json           | 464 +++++++++++++++++++++
 17 files changed, 1155 insertions(+), 118 deletions(-)

diff --git a/backend/plugins/q_dev/api/blueprint_v200.go 
b/backend/plugins/q_dev/api/blueprint_v200.go
index e3b845cb8..d7606a275 100644
--- a/backend/plugins/q_dev/api/blueprint_v200.go
+++ b/backend/plugins/q_dev/api/blueprint_v200.go
@@ -72,6 +72,10 @@ func makeDataSourcePipelinePlanV200(
                        ConnectionId: s3Slice.ConnectionId,
                        S3Prefix:     s3Slice.Prefix,
                        ScopeId:      s3Slice.Id,
+                       AccountId:    s3Slice.AccountId,
+                       BasePath:     s3Slice.BasePath,
+                       Year:         s3Slice.Year,
+                       Month:        s3Slice.Month,
                }
 
                // Pass empty entities array to enable all subtasks
diff --git a/backend/plugins/q_dev/impl/impl.go 
b/backend/plugins/q_dev/impl/impl.go
index 80118212e..e38fe7ad7 100644
--- a/backend/plugins/q_dev/impl/impl.go
+++ b/backend/plugins/q_dev/impl/impl.go
@@ -57,6 +57,7 @@ func (p QDev) GetTablesInfo() []dal.Tabler {
                &models.QDevUserData{},
                &models.QDevS3FileMeta{},
                &models.QDevS3Slice{},
+               &models.QDevUserReport{},
        }
 }
 
@@ -117,10 +118,30 @@ func (p QDev) PrepareTaskData(taskCtx plugin.TaskContext, 
options map[string]int
                identityClient = nil
        }
 
+       // Resolve S3 prefixes to scan
+       var s3Prefixes []string
+       if op.AccountId != "" {
+               // New-style scope: construct both report paths using region 
from connection
+               region := connection.Region
+               timePart := fmt.Sprintf("%04d", op.Year)
+               if op.Month != nil {
+                       timePart = fmt.Sprintf("%04d/%02d", op.Year, *op.Month)
+               }
+               base := fmt.Sprintf("%s/AWSLogs/%s/KiroLogs", op.BasePath, 
op.AccountId)
+               s3Prefixes = []string{
+                       fmt.Sprintf("%s/by_user_analytic/%s/%s", base, region, 
timePart),
+                       fmt.Sprintf("%s/user_report/%s/%s", base, region, 
timePart),
+               }
+       } else {
+               // Legacy scope: use S3Prefix directly
+               s3Prefixes = []string{op.S3Prefix}
+       }
+
        return &tasks.QDevTaskData{
                Options:        &op,
                S3Client:       s3Client,
                IdentityClient: identityClient,
+               S3Prefixes:     s3Prefixes,
        }, nil
 }
 
diff --git a/backend/plugins/q_dev/impl/impl_test.go 
b/backend/plugins/q_dev/impl/impl_test.go
index 97dea86e6..e61b53251 100644
--- a/backend/plugins/q_dev/impl/impl_test.go
+++ b/backend/plugins/q_dev/impl/impl_test.go
@@ -34,7 +34,7 @@ func TestQDev_BasicPluginMethods(t *testing.T) {
 
        // Test table info
        tables := plugin.GetTablesInfo()
-       assert.Len(t, tables, 4)
+       assert.Len(t, tables, 5)
 
        // Test subtask metas
        subtasks := plugin.SubTaskMetas()
@@ -48,7 +48,7 @@ func TestQDev_BasicPluginMethods(t *testing.T) {
 }
 
 func TestQDev_TaskDataStructure(t *testing.T) {
-       // Test that QDevTaskData has the expected structure
+       // Test that QDevTaskData has the expected structure (legacy mode)
        taskData := &tasks.QDevTaskData{
                Options: &tasks.QDevOptions{
                        ConnectionId: 1,
@@ -61,6 +61,7 @@ func TestQDev_TaskDataStructure(t *testing.T) {
                        StoreId: "d-1234567890",
                        Region:  "us-west-2",
                },
+               S3Prefixes: []string{"test/"},
        }
 
        assert.NotNil(t, taskData.Options)
@@ -72,6 +73,36 @@ func TestQDev_TaskDataStructure(t *testing.T) {
        assert.Equal(t, "test-bucket", taskData.S3Client.Bucket)
        assert.Equal(t, "d-1234567890", taskData.IdentityClient.StoreId)
        assert.Equal(t, "us-west-2", taskData.IdentityClient.Region)
+       assert.Equal(t, []string{"test/"}, taskData.S3Prefixes)
+}
+
+func TestQDev_TaskDataWithAccountId(t *testing.T) {
+       // Test new-style scope with AccountId and multiple S3Prefixes
+       month := 1
+       taskData := &tasks.QDevTaskData{
+               Options: &tasks.QDevOptions{
+                       ConnectionId: 1,
+                       AccountId:    "034362076319",
+                       BasePath:     "user-report",
+                       Year:         2026,
+                       Month:        &month,
+               },
+               S3Client: &tasks.QDevS3Client{
+                       Bucket: "test-bucket",
+               },
+               S3Prefixes: []string{
+                       
"user-report/AWSLogs/034362076319/KiroLogs/by_user_analytic/us-east-1/2026/01",
+                       
"user-report/AWSLogs/034362076319/KiroLogs/user_report/us-east-1/2026/01",
+               },
+       }
+
+       assert.Equal(t, "034362076319", taskData.Options.AccountId)
+       assert.Equal(t, "user-report", taskData.Options.BasePath)
+       assert.Equal(t, 2026, taskData.Options.Year)
+       assert.Equal(t, &month, taskData.Options.Month)
+       assert.Len(t, taskData.S3Prefixes, 2)
+       assert.Contains(t, taskData.S3Prefixes[0], "by_user_analytic")
+       assert.Contains(t, taskData.S3Prefixes[1], "user_report")
 }
 
 func TestQDev_TaskDataWithoutIdentityClient(t *testing.T) {
@@ -83,10 +114,12 @@ func TestQDev_TaskDataWithoutIdentityClient(t *testing.T) {
                S3Client: &tasks.QDevS3Client{
                        Bucket: "test-bucket",
                },
-               IdentityClient: nil, // No identity client
+               IdentityClient: nil,
+               S3Prefixes:     []string{"some-prefix/"},
        }
 
        assert.NotNil(t, taskData.Options)
        assert.NotNil(t, taskData.S3Client)
        assert.Nil(t, taskData.IdentityClient)
+       assert.Len(t, taskData.S3Prefixes, 1)
 }
diff --git a/backend/plugins/q_dev/models/migrationscripts/register.go 
b/backend/plugins/q_dev/models/migrationscripts/20260219_add_user_report_table.go
similarity index 55%
copy from backend/plugins/q_dev/models/migrationscripts/register.go
copy to 
backend/plugins/q_dev/models/migrationscripts/20260219_add_user_report_table.go
index 86971e539..5f38c7407 100644
--- a/backend/plugins/q_dev/models/migrationscripts/register.go
+++ 
b/backend/plugins/q_dev/models/migrationscripts/20260219_add_user_report_table.go
@@ -18,18 +18,25 @@ limitations under the License.
 package migrationscripts
 
 import (
-       "github.com/apache/incubator-devlake/core/plugin"
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/helpers/migrationhelper"
+       
"github.com/apache/incubator-devlake/plugins/q_dev/models/migrationscripts/archived"
 )
 
-// All return all migration scripts
-func All() []plugin.MigrationScript {
-       return []plugin.MigrationScript{
-               new(initTables),
-               new(modifyFileMetaTable),
-               new(addDisplayNameFields),
-               new(addMissingMetrics),
-               new(addS3SliceTable),
-               new(addScopeConfigIdToS3Slice),
-               new(addScopeIdFields),
-       }
+type addUserReportTable struct{}
+
+func (*addUserReportTable) Up(basicRes context.BasicRes) errors.Error {
+       return migrationhelper.AutoMigrateTables(
+               basicRes,
+               &archived.QDevUserReport{},
+       )
+}
+
+func (*addUserReportTable) Version() uint64 {
+       return 20260219000001
+}
+
+func (*addUserReportTable) Name() string {
+       return "Add user_report table for Kiro credits/subscription metrics"
 }
diff --git a/backend/plugins/q_dev/models/migrationscripts/register.go 
b/backend/plugins/q_dev/models/migrationscripts/20260220_add_account_id_to_s3_slice.go
similarity index 50%
copy from backend/plugins/q_dev/models/migrationscripts/register.go
copy to 
backend/plugins/q_dev/models/migrationscripts/20260220_add_account_id_to_s3_slice.go
index 86971e539..71a13c7b2 100644
--- a/backend/plugins/q_dev/models/migrationscripts/register.go
+++ 
b/backend/plugins/q_dev/models/migrationscripts/20260220_add_account_id_to_s3_slice.go
@@ -18,18 +18,34 @@ 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/plugin"
 )
 
-// All return all migration scripts
-func All() []plugin.MigrationScript {
-       return []plugin.MigrationScript{
-               new(initTables),
-               new(modifyFileMetaTable),
-               new(addDisplayNameFields),
-               new(addMissingMetrics),
-               new(addS3SliceTable),
-               new(addScopeConfigIdToS3Slice),
-               new(addScopeIdFields),
+var _ plugin.MigrationScript = (*addAccountIdToS3Slice)(nil)
+
+type addAccountIdToS3Slice struct{}
+
+func (*addAccountIdToS3Slice) Up(basicRes context.BasicRes) errors.Error {
+       db := basicRes.GetDal()
+
+       err := db.Exec(`
+               ALTER TABLE _tool_q_dev_s3_slices
+               ADD COLUMN IF NOT EXISTS account_id VARCHAR(255) DEFAULT NULL
+       `)
+       if err != nil {
+               // Try alternative syntax for databases that don't support IF 
NOT EXISTS
+               _ = db.Exec(`ALTER TABLE _tool_q_dev_s3_slices ADD COLUMN 
account_id VARCHAR(255) DEFAULT NULL`)
        }
+
+       return nil
+}
+
+func (*addAccountIdToS3Slice) Version() uint64 {
+       return 20260220000001
+}
+
+func (*addAccountIdToS3Slice) Name() string {
+       return "add account_id column to _tool_q_dev_s3_slices for 
auto-constructing S3 prefixes"
 }
diff --git 
a/backend/plugins/q_dev/models/migrationscripts/archived/user_report.go 
b/backend/plugins/q_dev/models/migrationscripts/archived/user_report.go
new file mode 100644
index 000000000..53bef49b1
--- /dev/null
+++ b/backend/plugins/q_dev/models/migrationscripts/archived/user_report.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 archived
+
+import (
+       "time"
+
+       
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+)
+
+type QDevUserReport struct {
+       archived.Model
+       ConnectionId       uint64    `gorm:"primaryKey"`
+       UserId             string    `gorm:"index" json:"userId"`
+       Date               time.Time `gorm:"index" json:"date"`
+       DisplayName        string    `gorm:"type:varchar(255)" 
json:"displayName"`
+       ScopeId            string    `gorm:"index;type:varchar(255)" 
json:"scopeId"`
+       ClientType         string    `gorm:"type:varchar(50)" json:"clientType"`
+       SubscriptionTier   string    `gorm:"type:varchar(50)" 
json:"subscriptionTier"`
+       ProfileId          string    `gorm:"type:varchar(512)" json:"profileId"`
+       ChatConversations  int       `json:"chatConversations"`
+       CreditsUsed        float64   `json:"creditsUsed"`
+       OverageCap         float64   `json:"overageCap"`
+       OverageCreditsUsed float64   `json:"overageCreditsUsed"`
+       OverageEnabled     bool      `json:"overageEnabled"`
+       TotalMessages      int       `json:"totalMessages"`
+}
+
+func (QDevUserReport) TableName() string {
+       return "_tool_q_dev_user_report"
+}
diff --git a/backend/plugins/q_dev/models/migrationscripts/register.go 
b/backend/plugins/q_dev/models/migrationscripts/register.go
index 86971e539..825ab5658 100644
--- a/backend/plugins/q_dev/models/migrationscripts/register.go
+++ b/backend/plugins/q_dev/models/migrationscripts/register.go
@@ -31,5 +31,7 @@ func All() []plugin.MigrationScript {
                new(addS3SliceTable),
                new(addScopeConfigIdToS3Slice),
                new(addScopeIdFields),
+               new(addUserReportTable),
+               new(addAccountIdToS3Slice),
        }
 }
diff --git a/backend/plugins/q_dev/models/s3_slice.go 
b/backend/plugins/q_dev/models/s3_slice.go
index c844d1634..e918258a9 100644
--- a/backend/plugins/q_dev/models/s3_slice.go
+++ b/backend/plugins/q_dev/models/s3_slice.go
@@ -33,6 +33,7 @@ type QDevS3Slice struct {
        Id           string `json:"id" mapstructure:"id" 
gorm:"primaryKey;type:varchar(512)"`
        Prefix       string `json:"prefix" mapstructure:"prefix" 
gorm:"type:varchar(512);not null"`
        BasePath     string `json:"basePath" mapstructure:"basePath" 
gorm:"type:varchar(512)"`
+       AccountId    string `json:"accountId,omitempty" 
mapstructure:"accountId" gorm:"type:varchar(255)"`
        Year         int    `json:"year" mapstructure:"year" gorm:"not null"`
        Month        *int   `json:"month,omitempty" mapstructure:"month"`
 
@@ -61,6 +62,7 @@ func (s *QDevS3Slice) normalize(strict bool) error {
        }
 
        s.BasePath = cleanPath(s.BasePath)
+       s.AccountId = strings.TrimSpace(s.AccountId)
        s.Prefix = cleanPath(selectNonEmpty(s.Prefix, s.Id))
 
        if s.Year <= 0 {
@@ -81,23 +83,37 @@ func (s *QDevS3Slice) normalize(strict bool) error {
                }
        }
 
-       if s.Prefix == "" {
-               s.Prefix = buildPrefix(s.BasePath, s.Year, s.Month)
-       }
+       if s.AccountId != "" {
+               // New-style scope: construct a logical identifier from 
component parts
+               s.Prefix = buildPrefixWithAccount(s.BasePath, s.AccountId, 
s.Year, s.Month)
+       } else {
+               // Legacy scope: derive prefix from basePath + year + month
+               if s.Prefix == "" {
+                       s.Prefix = buildPrefix(s.BasePath, s.Year, s.Month)
+               }
 
-       prefix := buildPrefix(s.BasePath, s.Year, s.Month)
-       if prefix != "" {
-               s.Prefix = prefix
+               prefix := buildPrefix(s.BasePath, s.Year, s.Month)
+               if prefix != "" {
+                       s.Prefix = prefix
+               }
        }
 
        if s.Id == "" {
                s.Id = s.Prefix
        }
 
-       if s.Month != nil {
-               s.Name = fmt.Sprintf("%04d-%02d", s.Year, *s.Month)
-       } else if s.Year > 0 {
-               s.Name = fmt.Sprintf("%04d", s.Year)
+       if s.AccountId != "" {
+               if s.Month != nil {
+                       s.Name = fmt.Sprintf("%s %04d-%02d", s.AccountId, 
s.Year, *s.Month)
+               } else if s.Year > 0 {
+                       s.Name = fmt.Sprintf("%s %04d", s.AccountId, s.Year)
+               }
+       } else {
+               if s.Month != nil {
+                       s.Name = fmt.Sprintf("%04d-%02d", s.Year, *s.Month)
+               } else if s.Year > 0 {
+                       s.Name = fmt.Sprintf("%04d", s.Year)
+               }
        }
 
        if s.FullName == "" {
@@ -150,6 +166,14 @@ func (s QDevS3Slice) ScopeName() string {
        if s.Name != "" {
                return s.Name
        }
+       if s.AccountId != "" {
+               if s.Month != nil {
+                       return fmt.Sprintf("%s %04d-%02d", s.AccountId, s.Year, 
*s.Month)
+               }
+               if s.Year > 0 {
+                       return fmt.Sprintf("%s %04d", s.AccountId, s.Year)
+               }
+       }
        if s.Month != nil {
                return fmt.Sprintf("%04d-%02d", s.Year, *s.Month)
        }
@@ -186,6 +210,20 @@ type QDevS3SliceParams struct {
 
 var _ plugin.ToolLayerScope = (*QDevS3Slice)(nil)
 
+func buildPrefixWithAccount(basePath string, accountId string, year int, month 
*int) string {
+       parts := splitPath(basePath)
+       if accountId != "" {
+               parts = append(parts, accountId)
+       }
+       if year > 0 {
+               parts = append(parts, fmt.Sprintf("%04d", year))
+       }
+       if month != nil {
+               parts = append(parts, fmt.Sprintf("%02d", *month))
+       }
+       return strings.Join(parts, "/")
+}
+
 func buildPrefix(basePath string, year int, month *int) string {
        parts := splitPath(basePath)
        if year > 0 {
diff --git a/backend/plugins/q_dev/models/user_report.go 
b/backend/plugins/q_dev/models/user_report.go
new file mode 100644
index 000000000..f64090e89
--- /dev/null
+++ b/backend/plugins/q_dev/models/user_report.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 models
+
+import (
+       "time"
+
+       "github.com/apache/incubator-devlake/core/models/common"
+)
+
+type QDevUserReport struct {
+       common.Model
+       ConnectionId       uint64    `gorm:"primaryKey"`
+       UserId             string    `gorm:"index" json:"userId"`
+       Date               time.Time `gorm:"index" json:"date"`
+       DisplayName        string    `gorm:"type:varchar(255)" 
json:"displayName"`
+       ScopeId            string    `gorm:"index;type:varchar(255)" 
json:"scopeId"`
+       ClientType         string    `gorm:"type:varchar(50)" json:"clientType"`
+       SubscriptionTier   string    `gorm:"type:varchar(50)" 
json:"subscriptionTier"`
+       ProfileId          string    `gorm:"type:varchar(512)" json:"profileId"`
+       ChatConversations  int       `json:"chatConversations"`
+       CreditsUsed        float64   `json:"creditsUsed"`
+       OverageCap         float64   `json:"overageCap"`
+       OverageCreditsUsed float64   `json:"overageCreditsUsed"`
+       OverageEnabled     bool      `json:"overageEnabled"`
+       TotalMessages      int       `json:"totalMessages"`
+}
+
+func (QDevUserReport) TableName() string {
+       return "_tool_q_dev_user_report"
+}
diff --git a/backend/plugins/q_dev/tasks/s3_data_extractor.go 
b/backend/plugins/q_dev/tasks/s3_data_extractor.go
index 03f8a6f5f..919c4dbe6 100644
--- a/backend/plugins/q_dev/tasks/s3_data_extractor.go
+++ b/backend/plugins/q_dev/tasks/s3_data_extractor.go
@@ -124,6 +124,14 @@ func processCSVData(taskCtx plugin.SubTaskContext, db 
dal.Dal, reader io.ReadClo
                return errors.Convert(err)
        }
 
+       // Auto-detect CSV format from headers
+       isNewFormat := detectUserReportFormat(headers)
+       if isNewFormat {
+               taskCtx.GetLogger().Debug("Detected new user_report CSV format")
+       } else {
+               taskCtx.GetLogger().Debug("Detected old by_user_analytic CSV 
format")
+       }
+
        // 逐行读取数据
        for {
                record, err := csvReader.Read()
@@ -134,22 +142,135 @@ func processCSVData(taskCtx plugin.SubTaskContext, db 
dal.Dal, reader io.ReadClo
                        return errors.Convert(err)
                }
 
-               // 创建用户数据对象 (updated to include display name resolution)
-               userData, err := 
createUserDataWithDisplayName(taskCtx.GetLogger(), headers, record, fileMeta, 
data.IdentityClient)
-               if err != nil {
-                       return errors.Default.Wrap(err, "failed to create user 
data")
-               }
+               if isNewFormat {
+                       reportData, err := 
createUserReportData(taskCtx.GetLogger(), headers, record, fileMeta, 
data.IdentityClient)
+                       if err != nil {
+                               return errors.Default.Wrap(err, "failed to 
create user report data")
+                       }
+                       err = db.Create(reportData)
+                       if err != nil {
+                               return errors.Default.Wrap(err, "failed to save 
user report data")
+                       }
+               } else {
+                       // 创建用户数据对象 (updated to include display name resolution)
+                       userData, err := 
createUserDataWithDisplayName(taskCtx.GetLogger(), headers, record, fileMeta, 
data.IdentityClient)
+                       if err != nil {
+                               return errors.Default.Wrap(err, "failed to 
create user data")
+                       }
 
-               // Save to database - no need to check for duplicates since 
we're processing each file only once
-               err = db.Create(userData)
-               if err != nil {
-                       return errors.Default.Wrap(err, "failed to save user 
data")
+                       // Save to database - no need to check for duplicates 
since we're processing each file only once
+                       err = db.Create(userData)
+                       if err != nil {
+                               return errors.Default.Wrap(err, "failed to save 
user data")
+                       }
                }
        }
 
        return nil
 }
 
+// detectUserReportFormat checks CSV headers to determine if this is the new 
user_report format
+func detectUserReportFormat(headers []string) bool {
+       for _, h := range headers {
+               trimmed := strings.TrimSpace(h)
+               if trimmed == "Client_Type" || trimmed == "Credits_Used" {
+                       return true
+               }
+       }
+       return false
+}
+
+// createUserReportData creates a QDevUserReport from a new-format CSV record
+func createUserReportData(logger interface {
+       Debug(format string, a ...interface{})
+}, headers []string, record []string, fileMeta *models.QDevS3FileMeta, 
identityClient UserDisplayNameResolver) (*models.QDevUserReport, errors.Error) {
+       report := &models.QDevUserReport{
+               ConnectionId: fileMeta.ConnectionId,
+               ScopeId:      fileMeta.ScopeId,
+       }
+
+       // Build field map
+       fieldMap := make(map[string]string)
+       for i, header := range headers {
+               if i < len(record) {
+                       logger.Debug("Mapping header[%d]: '%s' -> '%s'", i, 
header, record[i])
+                       fieldMap[header] = record[i]
+                       trimmedHeader := strings.TrimSpace(header)
+                       if trimmedHeader != header {
+                               logger.Debug("Also adding trimmed header: 
'%s'", trimmedHeader)
+                               fieldMap[trimmedHeader] = record[i]
+                       }
+               }
+       }
+
+       // UserId
+       report.UserId = getStringField(fieldMap, "UserId")
+       if report.UserId == "" {
+               return nil, errors.Default.New("UserId not found in CSV record")
+       }
+
+       // DisplayName
+       report.DisplayName = resolveDisplayName(logger, report.UserId, 
identityClient)
+
+       // Date
+       dateStr := getStringField(fieldMap, "Date")
+       if dateStr == "" {
+               return nil, errors.Default.New("Date not found in CSV record")
+       }
+       var err error
+       report.Date, err = parseDate(dateStr)
+       if err != nil {
+               return nil, errors.Default.Wrap(err, "failed to parse date")
+       }
+
+       // String fields
+       report.ClientType = getStringField(fieldMap, "Client_Type")
+       report.SubscriptionTier = getStringField(fieldMap, "Subscription_Tier")
+       report.ProfileId = getStringField(fieldMap, "ProfileId")
+
+       // Numeric fields
+       report.ChatConversations = parseInt(fieldMap, "Chat_Conversations")
+       report.CreditsUsed = parseFloat(fieldMap, "Credits_Used")
+       report.OverageCap = parseFloat(fieldMap, "Overage_Cap")
+       report.OverageCreditsUsed = parseFloat(fieldMap, "Overage_Credits_Used")
+       report.OverageEnabled = parseBool(fieldMap, "Overage_Enabled")
+       report.TotalMessages = parseInt(fieldMap, "Total_Messages")
+
+       return report, nil
+}
+
+// getStringField returns the string value for a field, or empty string if not 
found
+func getStringField(fieldMap map[string]string, field string) string {
+       value, ok := fieldMap[field]
+       if !ok {
+               return ""
+       }
+       return value
+}
+
+// parseFloat extracts a float64 from the field map, returning 0 if missing or 
invalid
+func parseFloat(fieldMap map[string]string, field string) float64 {
+       value, ok := fieldMap[field]
+       if !ok {
+               return 0
+       }
+       f, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
+       if err != nil {
+               return 0
+       }
+       return f
+}
+
+// parseBool extracts a boolean from the field map, returning false if missing 
or invalid
+func parseBool(fieldMap map[string]string, field string) bool {
+       value, ok := fieldMap[field]
+       if !ok {
+               return false
+       }
+       trimmed := strings.TrimSpace(strings.ToLower(value))
+       return trimmed == "true" || trimmed == "1" || trimmed == "yes"
+}
+
 // UserDisplayNameResolver interface for resolving user display names
 type UserDisplayNameResolver interface {
        ResolveUserDisplayName(userId string) (string, error)
diff --git a/backend/plugins/q_dev/tasks/s3_data_extractor_test.go 
b/backend/plugins/q_dev/tasks/s3_data_extractor_test.go
index fbea299dc..0a5f808eb 100644
--- a/backend/plugins/q_dev/tasks/s3_data_extractor_test.go
+++ b/backend/plugins/q_dev/tasks/s3_data_extractor_test.go
@@ -419,6 +419,173 @@ func TestParseDate(t *testing.T) {
        }
 }
 
+func TestDetectUserReportFormat(t *testing.T) {
+       // New format: contains Client_Type
+       assert.True(t, detectUserReportFormat([]string{"UserId", "Date", 
"Client_Type", "Credits_Used"}))
+       // New format: contains Credits_Used
+       assert.True(t, detectUserReportFormat([]string{"UserId", "Date", 
"Credits_Used", "Total_Messages"}))
+       // Old format: code-level metrics
+       assert.False(t, detectUserReportFormat([]string{"UserId", "Date", 
"Chat_AICodeLines", "Inline_AICodeLines"}))
+       // Old format: no new-format indicators
+       assert.False(t, detectUserReportFormat([]string{"UserId", "Date", 
"CodeReview_FindingsCount"}))
+       // Empty headers
+       assert.False(t, detectUserReportFormat([]string{}))
+       // Whitespace-padded header still detected
+       assert.True(t, detectUserReportFormat([]string{"UserId", " Client_Type 
", "Date"}))
+}
+
+func TestCreateUserReportData_Success(t *testing.T) {
+       headers := []string{
+               "UserId", "Date", "Client_Type", "Subscription_Tier", 
"ProfileId",
+               "Chat_Conversations", "Credits_Used", "Overage_Cap", 
"Overage_Credits_Used",
+               "Overage_Enabled", "Total_Messages",
+       }
+       record := []string{
+               "user-abc", "2026-01-15", "KIRO_IDE", "Pro", "profile-xyz",
+               "12", "45.5", "100.0", "5.25",
+               "true", "87",
+       }
+       fileMeta := &models.QDevS3FileMeta{
+               ConnectionId: 1,
+               ScopeId:      "scope-1",
+       }
+
+       mockIdentityClient := &MockIdentityClient{}
+       mockIdentityClient.On("ResolveUserDisplayName", 
"user-abc").Return("Alice Bob", nil)
+
+       mockLogger := &MockLogger{}
+       mockLogger.On("Debug", mock.Anything, mock.Anything).Return()
+
+       report, err := createUserReportData(mockLogger, headers, record, 
fileMeta, mockIdentityClient)
+
+       assert.NoError(t, err)
+       assert.NotNil(t, report)
+       assert.Equal(t, "user-abc", report.UserId)
+       assert.Equal(t, "Alice Bob", report.DisplayName)
+       assert.Equal(t, uint64(1), report.ConnectionId)
+       assert.Equal(t, "scope-1", report.ScopeId)
+       assert.Equal(t, "KIRO_IDE", report.ClientType)
+       assert.Equal(t, "Pro", report.SubscriptionTier)
+       assert.Equal(t, "profile-xyz", report.ProfileId)
+       assert.Equal(t, 12, report.ChatConversations)
+       assert.Equal(t, 45.5, report.CreditsUsed)
+       assert.Equal(t, 100.0, report.OverageCap)
+       assert.Equal(t, 5.25, report.OverageCreditsUsed)
+       assert.True(t, report.OverageEnabled)
+       assert.Equal(t, 87, report.TotalMessages)
+
+       expectedDate, _ := time.Parse("2006-01-02", "2026-01-15")
+       assert.Equal(t, expectedDate, report.Date)
+
+       mockIdentityClient.AssertExpectations(t)
+}
+
+func TestCreateUserReportData_MissingUserId(t *testing.T) {
+       headers := []string{"Date", "Client_Type", "Credits_Used"}
+       record := []string{"2026-01-15", "KIRO_IDE", "10.0"}
+       fileMeta := &models.QDevS3FileMeta{ConnectionId: 1}
+
+       mockLogger := &MockLogger{}
+       mockLogger.On("Debug", mock.Anything, mock.Anything).Return()
+
+       report, err := createUserReportData(mockLogger, headers, record, 
fileMeta, nil)
+
+       assert.Error(t, err)
+       assert.Nil(t, report)
+       assert.Contains(t, err.Error(), "UserId not found")
+}
+
+func TestCreateUserReportData_MissingDate(t *testing.T) {
+       headers := []string{"UserId", "Client_Type", "Credits_Used"}
+       record := []string{"user-abc", "KIRO_IDE", "10.0"}
+       fileMeta := &models.QDevS3FileMeta{ConnectionId: 1}
+
+       mockLogger := &MockLogger{}
+       mockLogger.On("Debug", mock.Anything, mock.Anything).Return()
+
+       report, err := createUserReportData(mockLogger, headers, record, 
fileMeta, nil)
+
+       assert.Error(t, err)
+       assert.Nil(t, report)
+       assert.Contains(t, err.Error(), "Date not found")
+}
+
+func TestCreateUserReportData_OverageDisabled(t *testing.T) {
+       headers := []string{"UserId", "Date", "Overage_Enabled", "Credits_Used"}
+       record := []string{"user-abc", "2026-01-15", "false", "10.0"}
+       fileMeta := &models.QDevS3FileMeta{ConnectionId: 1}
+
+       mockLogger := &MockLogger{}
+       mockLogger.On("Debug", mock.Anything, mock.Anything).Return()
+
+       report, err := createUserReportData(mockLogger, headers, record, 
fileMeta, nil)
+
+       assert.NoError(t, err)
+       assert.False(t, report.OverageEnabled)
+}
+
+func TestCreateUserReportData_InvalidNumericValues(t *testing.T) {
+       headers := []string{"UserId", "Date", "Credits_Used", 
"Chat_Conversations", "Total_Messages"}
+       record := []string{"user-abc", "2026-01-15", "not-a-float", 
"not-an-int", ""}
+       fileMeta := &models.QDevS3FileMeta{ConnectionId: 1}
+
+       mockLogger := &MockLogger{}
+       mockLogger.On("Debug", mock.Anything, mock.Anything).Return()
+
+       report, err := createUserReportData(mockLogger, headers, record, 
fileMeta, nil)
+
+       assert.NoError(t, err)
+       assert.Equal(t, float64(0), report.CreditsUsed)
+       assert.Equal(t, 0, report.ChatConversations)
+       assert.Equal(t, 0, report.TotalMessages)
+}
+
+func TestParseFloat(t *testing.T) {
+       fieldMap := map[string]string{
+               "ValidFloat":    "3.14",
+               "ZeroFloat":     "0",
+               "NegativeFloat": "-2.5",
+               "IntegerValue":  "42",
+               "InvalidFloat":  "not-a-number",
+               "EmptyString":   "",
+               "Whitespace":    "  1.5  ",
+       }
+
+       assert.Equal(t, 3.14, parseFloat(fieldMap, "ValidFloat"))
+       assert.Equal(t, float64(0), parseFloat(fieldMap, "ZeroFloat"))
+       assert.Equal(t, -2.5, parseFloat(fieldMap, "NegativeFloat"))
+       assert.Equal(t, float64(42), parseFloat(fieldMap, "IntegerValue"))
+       assert.Equal(t, float64(0), parseFloat(fieldMap, "InvalidFloat"))
+       assert.Equal(t, float64(0), parseFloat(fieldMap, "EmptyString"))
+       assert.Equal(t, 1.5, parseFloat(fieldMap, "Whitespace"))
+       assert.Equal(t, float64(0), parseFloat(fieldMap, "NonExistentField"))
+}
+
+func TestParseBool(t *testing.T) {
+       fieldMap := map[string]string{
+               "TrueValue":     "true",
+               "TrueUpper":     "True",
+               "TrueOne":       "1",
+               "TrueYes":       "yes",
+               "FalseValue":    "false",
+               "FalseZero":     "0",
+               "EmptyString":   "",
+               "InvalidBool":   "maybe",
+               "WhitespaceVal": "  true  ",
+       }
+
+       assert.True(t, parseBool(fieldMap, "TrueValue"))
+       assert.True(t, parseBool(fieldMap, "TrueUpper"))
+       assert.True(t, parseBool(fieldMap, "TrueOne"))
+       assert.True(t, parseBool(fieldMap, "TrueYes"))
+       assert.False(t, parseBool(fieldMap, "FalseValue"))
+       assert.False(t, parseBool(fieldMap, "FalseZero"))
+       assert.False(t, parseBool(fieldMap, "EmptyString"))
+       assert.False(t, parseBool(fieldMap, "InvalidBool"))
+       assert.True(t, parseBool(fieldMap, "WhitespaceVal"))
+       assert.False(t, parseBool(fieldMap, "NonExistentField"))
+}
+
 func TestParseInt(t *testing.T) {
        fieldMap := map[string]string{
                "ValidInt":    "42",
diff --git a/backend/plugins/q_dev/tasks/s3_file_collector.go 
b/backend/plugins/q_dev/tasks/s3_file_collector.go
index ae88fb97a..9d40919ae 100644
--- a/backend/plugins/q_dev/tasks/s3_file_collector.go
+++ b/backend/plugins/q_dev/tasks/s3_file_collector.go
@@ -30,81 +30,79 @@ import (
 
 var _ plugin.SubTaskEntryPoint = CollectQDevS3Files
 
-// CollectQDevS3Files 收集S3文件元数据
+// CollectQDevS3Files collects S3 file metadata
 func CollectQDevS3Files(taskCtx plugin.SubTaskContext) errors.Error {
        data := taskCtx.GetData().(*QDevTaskData)
        db := taskCtx.GetDal()
 
-       // 列出指定前缀下的所有对象
-       var continuationToken *string
-       prefix := data.Options.S3Prefix
-       if prefix != "" && !strings.HasSuffix(prefix, "/") {
-               prefix = prefix + "/"
-       }
-
        taskCtx.SetProgress(0, -1)
 
-       for {
-               input := &s3.ListObjectsV2Input{
-                       Bucket:            aws.String(data.S3Client.Bucket),
-                       Prefix:            aws.String(prefix),
-                       ContinuationToken: continuationToken,
+       for _, rawPrefix := range data.S3Prefixes {
+               prefix := rawPrefix
+               if prefix != "" && !strings.HasSuffix(prefix, "/") {
+                       prefix = prefix + "/"
                }
 
-               result, err := data.S3Client.S3.ListObjectsV2(input)
-               if err != nil {
-                       return errors.Convert(err)
-               }
+               taskCtx.GetLogger().Info("Scanning S3 prefix: %s", prefix)
 
-               // 处理每个CSV文件
-               for _, object := range result.Contents {
-                       // Only process CSV files
-                       if !strings.HasSuffix(*object.Key, ".csv") {
-                               taskCtx.GetLogger().Debug("Skipping non-CSV 
file: %s", *object.Key)
-                               continue
+               var continuationToken *string
+               for {
+                       input := &s3.ListObjectsV2Input{
+                               Bucket:            
aws.String(data.S3Client.Bucket),
+                               Prefix:            aws.String(prefix),
+                               ContinuationToken: continuationToken,
                        }
 
-                       // Check if this file already exists in our database
-                       existingFile := &models.QDevS3FileMeta{}
-                       err = db.First(existingFile, dal.Where("connection_id = 
? AND s3_path = ?",
-                               data.Options.ConnectionId, *object.Key))
+                       result, err := data.S3Client.S3.ListObjectsV2(input)
+                       if err != nil {
+                               return errors.Convert(err)
+                       }
 
-                       if err == nil {
-                               // File already exists in database, skip it if 
it's already processed
-                               if existingFile.Processed {
-                                       taskCtx.GetLogger().Debug("Skipping 
already processed file: %s", *object.Key)
+                       for _, object := range result.Contents {
+                               // Only process CSV files
+                               if !strings.HasSuffix(*object.Key, ".csv") {
+                                       taskCtx.GetLogger().Debug("Skipping 
non-CSV file: %s", *object.Key)
                                        continue
                                }
-                               // Otherwise, we'll keep the existing record 
(which is still marked as unprocessed)
-                               taskCtx.GetLogger().Debug("Found existing 
unprocessed file: %s", *object.Key)
-                               continue
-                       } else if !db.IsErrorNotFound(err) {
-                               return errors.Default.Wrap(err, "failed to 
query existing file metadata")
-                       }
 
-                       // This is a new file, save its metadata
-                       fileMeta := &models.QDevS3FileMeta{
-                               ConnectionId: data.Options.ConnectionId,
-                               FileName:     *object.Key,
-                               S3Path:       *object.Key,
-                               ScopeId:      data.Options.ScopeId,
-                               Processed:    false,
-                       }
+                               // Check if this file already exists in our 
database
+                               existingFile := &models.QDevS3FileMeta{}
+                               err = db.First(existingFile, 
dal.Where("connection_id = ? AND s3_path = ?",
+                                       data.Options.ConnectionId, *object.Key))
+
+                               if err == nil {
+                                       if existingFile.Processed {
+                                               
taskCtx.GetLogger().Debug("Skipping already processed file: %s", *object.Key)
+                                               continue
+                                       }
+                                       taskCtx.GetLogger().Debug("Found 
existing unprocessed file: %s", *object.Key)
+                                       continue
+                               } else if !db.IsErrorNotFound(err) {
+                                       return errors.Default.Wrap(err, "failed 
to query existing file metadata")
+                               }
 
-                       err = db.Create(fileMeta)
-                       if err != nil {
-                               return errors.Default.Wrap(err, "failed to 
create file metadata")
+                               fileMeta := &models.QDevS3FileMeta{
+                                       ConnectionId: data.Options.ConnectionId,
+                                       FileName:     *object.Key,
+                                       S3Path:       *object.Key,
+                                       ScopeId:      data.Options.ScopeId,
+                                       Processed:    false,
+                               }
+
+                               err = db.Create(fileMeta)
+                               if err != nil {
+                                       return errors.Default.Wrap(err, "failed 
to create file metadata")
+                               }
+
+                               taskCtx.IncProgress(1)
                        }
 
-                       taskCtx.IncProgress(1)
-               }
+                       if !*result.IsTruncated {
+                               break
+                       }
 
-               // 如果没有更多对象,退出循环
-               if !*result.IsTruncated {
-                       break
+                       continuationToken = result.NextContinuationToken
                }
-
-               continuationToken = result.NextContinuationToken
        }
 
        return nil
diff --git a/backend/plugins/q_dev/tasks/task_data.go 
b/backend/plugins/q_dev/tasks/task_data.go
index 00c58f11e..3fd3c6584 100644
--- a/backend/plugins/q_dev/tasks/task_data.go
+++ b/backend/plugins/q_dev/tasks/task_data.go
@@ -29,12 +29,17 @@ type QDevOptions struct {
        ConnectionId uint64 `json:"connectionId"`
        S3Prefix     string `json:"s3Prefix"`
        ScopeId      string `json:"scopeId"`
+       AccountId    string `json:"accountId"`
+       BasePath     string `json:"basePath"`
+       Year         int    `json:"year"`
+       Month        *int   `json:"month"`
 }
 
 type QDevTaskData struct {
        Options        *QDevOptions
        S3Client       *QDevS3Client
-       IdentityClient *QDevIdentityClient // New field for Identity Center 
client
+       IdentityClient *QDevIdentityClient
+       S3Prefixes     []string
 }
 
 type QDevS3Client struct {
diff --git a/backend/plugins/q_dev/tasks/task_data_test.go 
b/backend/plugins/q_dev/tasks/task_data_test.go
index cb8f75437..757f27428 100644
--- a/backend/plugins/q_dev/tasks/task_data_test.go
+++ b/backend/plugins/q_dev/tasks/task_data_test.go
@@ -38,6 +38,7 @@ func TestQDevTaskData_WithIdentityClient(t *testing.T) {
                        StoreId: "d-1234567890",
                        Region:  "us-west-2",
                },
+               S3Prefixes: []string{"test-prefix/"},
        }
 
        assert.NotNil(t, taskData.IdentityClient)
@@ -45,6 +46,7 @@ func TestQDevTaskData_WithIdentityClient(t *testing.T) {
        assert.Equal(t, "us-west-2", taskData.IdentityClient.Region)
        assert.NotNil(t, taskData.S3Client)
        assert.NotNil(t, taskData.Options)
+       assert.Equal(t, []string{"test-prefix/"}, taskData.S3Prefixes)
 }
 
 func TestQDevTaskData_WithoutIdentityClient(t *testing.T) {
@@ -68,9 +70,14 @@ func TestQDevTaskData_WithoutIdentityClient(t *testing.T) {
 }
 
 func TestQDevTaskData_AllFields(t *testing.T) {
+       month := 3
        options := &QDevOptions{
                ConnectionId: 123,
                S3Prefix:     "data/q-dev/",
+               AccountId:    "034362076319",
+               BasePath:     "user-report",
+               Year:         2026,
+               Month:        &month,
        }
 
        s3Client := &QDevS3Client{
@@ -87,6 +94,10 @@ func TestQDevTaskData_AllFields(t *testing.T) {
                Options:        options,
                S3Client:       s3Client,
                IdentityClient: identityClient,
+               S3Prefixes: []string{
+                       
"user-report/AWSLogs/034362076319/KiroLogs/by_user_analytic/us-east-1/2026/03",
+                       
"user-report/AWSLogs/034362076319/KiroLogs/user_report/us-east-1/2026/03",
+               },
        }
 
        // Verify all fields are properly set
@@ -97,9 +108,14 @@ func TestQDevTaskData_AllFields(t *testing.T) {
        // Verify nested field access
        assert.Equal(t, uint64(123), taskData.Options.ConnectionId)
        assert.Equal(t, "data/q-dev/", taskData.Options.S3Prefix)
+       assert.Equal(t, "034362076319", taskData.Options.AccountId)
+       assert.Equal(t, "user-report", taskData.Options.BasePath)
+       assert.Equal(t, 2026, taskData.Options.Year)
+       assert.Equal(t, &month, taskData.Options.Month)
        assert.Equal(t, "my-data-bucket", taskData.S3Client.Bucket)
        assert.Equal(t, "d-9876543210", taskData.IdentityClient.StoreId)
        assert.Equal(t, "eu-west-1", taskData.IdentityClient.Region)
+       assert.Len(t, taskData.S3Prefixes, 2)
 }
 
 func TestQDevTaskData_EmptyStruct(t *testing.T) {
diff --git a/config-ui/src/plugins/register/q-dev/data-scope.tsx 
b/config-ui/src/plugins/register/q-dev/data-scope.tsx
index f6aa5a9b0..e576591b8 100644
--- a/config-ui/src/plugins/register/q-dev/data-scope.tsx
+++ b/config-ui/src/plugins/register/q-dev/data-scope.tsx
@@ -26,6 +26,7 @@ interface ScopeData {
   year?: number;
   month?: number | null;
   basePath?: string;
+  accountId?: string;
 }
 
 interface ScopeItem {
@@ -46,7 +47,7 @@ const CURRENT_YEAR = new Date().getUTCFullYear();
 const MONTHS = Array.from({ length: 12 }, (_, idx) => idx + 1);
 const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 
'Sep', 'Oct', 'Nov', 'Dec'];
 
-const DEFAULT_BASE_PATH = 'user-report/AWSLogs';
+const DEFAULT_BASE_PATH = 'user-report';
 
 const ensureLeadingZero = (value: number) => value.toString().padStart(2, '0');
 
@@ -92,11 +93,14 @@ const extractScopeMeta = (item: ScopeItem) => {
 
   const basePath = normalizeBasePath(data.basePath ?? (baseSegments.length ? 
baseSegments.join('/') : ''));
 
+  const accountId = data.accountId ?? '';
+
   return {
     basePath,
     year: typeof year === 'number' ? year : null,
     month,
     prefix,
+    accountId,
   };
 };
 
@@ -110,12 +114,25 @@ const deriveBasePathFromSelection = (items: ScopeItem[]) 
=> {
   return undefined;
 };
 
-const buildPrefix = (basePath: string, year: number, month: number | null) => {
+const deriveAccountIdFromSelection = (items: ScopeItem[]) => {
+  for (const item of items) {
+    const meta = extractScopeMeta(item);
+    if (meta.accountId) {
+      return meta.accountId;
+    }
+  }
+  return undefined;
+};
+
+const buildPrefix = (basePath: string, year: number, month: number | null, 
accountId?: string) => {
   const segments = [] as string[];
   const sanitizedBase = normalizeBasePath(basePath);
   if (sanitizedBase) {
     segments.push(sanitizedBase);
   }
+  if (accountId) {
+    segments.push(accountId);
+  }
   segments.push(String(year));
   if (month !== null && month !== undefined) {
     segments.push(ensureLeadingZero(month));
@@ -123,13 +140,14 @@ const buildPrefix = (basePath: string, year: number, 
month: number | null) => {
   return segments.join('/');
 };
 
-const createScopeItem = (basePath: string, year: number, month: number | 
null): ScopeItem => {
+const createScopeItem = (basePath: string, year: number, month: number | null, 
accountId?: string): ScopeItem => {
   const sanitizedBase = normalizeBasePath(basePath);
-  const prefix = buildPrefix(sanitizedBase, year, month);
+  const prefix = buildPrefix(sanitizedBase, year, month, accountId);
   const isFullYear = month === null;
-  const name = isFullYear
+  const timeLabel = isFullYear
     ? `${year} (Full Year)`
     : `${year}-${ensureLeadingZero(month as number)} (${MONTH_LABELS[(month as 
number) - 1]})`;
+  const name = accountId ? `${accountId} ${timeLabel}` : timeLabel;
 
   return {
     id: prefix,
@@ -137,6 +155,7 @@ const createScopeItem = (basePath: string, year: number, 
month: number | null):
     fullName: prefix,
     data: {
       basePath: sanitizedBase,
+      accountId: accountId || undefined,
       prefix,
       year,
       month,
@@ -165,6 +184,7 @@ const MONTH_OPTIONS = MONTHS.map((value) => ({
 
 type FormValues = {
   basePath: string;
+  accountId: string;
   year: number;
   mode: 'year' | 'months';
   months?: number[];
@@ -185,20 +205,32 @@ export const QDevDataScope = ({
     [selectedItems],
   );
 
+  const derivedAccountId = useMemo(
+    () => deriveAccountIdFromSelection(selectedItems) ?? '',
+    [selectedItems],
+  );
+
   useEffect(() => {
     if (!form.isFieldsTouched(['basePath'])) {
       form.setFieldsValue({ basePath: derivedBasePath });
     }
   }, [derivedBasePath, form]);
 
+  useEffect(() => {
+    if (!form.isFieldsTouched(['accountId'])) {
+      form.setFieldsValue({ accountId: derivedAccountId });
+    }
+  }, [derivedAccountId, form]);
+
   useEffect(() => {
     form.setFieldsValue({ mode: 'year', year: form.getFieldValue('year') ?? 
CURRENT_YEAR });
   }, [form]);
 
   const handleAdd = async () => {
-    const { basePath, year, mode, months = [] } = await form.validateFields();
+    const { basePath, accountId, year, mode, months = [] } = await 
form.validateFields();
 
     const normalizedBase = normalizeBasePath(basePath ?? '');
+    const normalizedAccountId = (accountId ?? '').trim();
     const normalizedYear = Number(year);
     if (!normalizedYear || Number.isNaN(normalizedYear)) {
       return;
@@ -209,6 +241,7 @@ export const QDevDataScope = ({
       const meta = extractScopeMeta(item);
       return (
         meta.basePath === normalizedBase &&
+        meta.accountId === normalizedAccountId &&
         meta.year === normalizedYear &&
         (meta.month === null || meta.month === undefined)
       );
@@ -223,14 +256,14 @@ export const QDevDataScope = ({
 
       const hasMonths = selectedItems.some((item) => {
         const meta = extractScopeMeta(item);
-        return meta.basePath === normalizedBase && meta.year === 
normalizedYear && meta.month !== null;
+        return meta.basePath === normalizedBase && meta.accountId === 
normalizedAccountId && meta.year === normalizedYear && meta.month !== null;
       });
 
       if (hasMonths) {
         return;
       }
 
-      const item = createScopeItem(normalizedBase, normalizedYear, null);
+      const item = createScopeItem(normalizedBase, normalizedYear, null, 
normalizedAccountId || undefined);
       if (!currentIds.has(item.id) && !disabledIds.has(item.id)) {
         additions.push(item);
       }
@@ -249,7 +282,7 @@ export const QDevDataScope = ({
           return;
         }
 
-        const item = createScopeItem(normalizedBase, normalizedYear, month);
+        const item = createScopeItem(normalizedBase, normalizedYear, month, 
normalizedAccountId || undefined);
         if (currentIds.has(item.id) || disabledIds.has(item.id)) {
           return;
         }
@@ -282,24 +315,34 @@ export const QDevDataScope = ({
       render: (_: unknown, item) => formatScopeLabel(item),
     },
     {
-      title: 'S3 Prefix',
+      title: 'Scope Path',
       dataIndex: 'id',
       key: 'prefix',
       render: (_: unknown, item) => {
         const meta = extractScopeMeta(item);
+        if (meta.accountId) {
+          const timePart = meta.month
+            ? `${meta.year}/${ensureLeadingZero(meta.month)}`
+            : `${meta.year}`;
+          return (
+            <Tooltip title={`Scans both by_user_analytic and user_report under 
AWSLogs/${meta.accountId}/KiroLogs/…/${timePart}`}>
+              <Typography.Text 
code>{meta.basePath}/…/{meta.accountId}/…/{timePart}</Typography.Text>
+            </Tooltip>
+          );
+        }
         return <Typography.Text code>{meta.prefix}</Typography.Text>;
       },
     },
     {
-      title: 'Base Path',
+      title: 'Account ID',
       dataIndex: 'id',
-      key: 'basePath',
+      key: 'accountId',
       render: (_: unknown, item) => {
         const meta = extractScopeMeta(item);
-        return meta.basePath ? (
-          <Typography.Text>{meta.basePath}</Typography.Text>
+        return meta.accountId ? (
+          <Typography.Text>{meta.accountId}</Typography.Text>
         ) : (
-          <Typography.Text type="secondary">(bucket root)</Typography.Text>
+          <Typography.Text type="secondary">—</Typography.Text>
         );
       },
     },
@@ -335,6 +378,7 @@ export const QDevDataScope = ({
         layout="inline"
         initialValues={{
           basePath: derivedBasePath,
+          accountId: derivedAccountId,
           year: CURRENT_YEAR,
           mode: 'year',
           months: [],
@@ -346,9 +390,18 @@ export const QDevDataScope = ({
           label="Base Path"
           name="basePath"
           style={{ flex: 1 }}
-          tooltip="Common prefix in S3 between the bucket root and the year 
directory"
+          tooltip="S3 prefix before the AWSLogs directory (e.g. 'user-report')"
+        >
+          <Input placeholder="e.g. user-report" />
+        </Form.Item>
+
+        <Form.Item
+          label="AWS Account ID"
+          name="accountId"
+          style={{ width: 200 }}
+          tooltip="AWS Account ID used in the S3 export path. When set, both 
by_user_analytic and user_report paths are scanned automatically."
         >
-          <Input placeholder="user-report/AWSLogs/.../us-east-1" />
+          <Input placeholder="e.g. 034362076319" />
         </Form.Item>
 
         <Form.Item label="Year" name="year" rules={[{ required: true, message: 
'Enter year' }]} style={{ width: 160 }}>
diff --git a/grafana/dashboards/qdev_user_data.json 
b/grafana/dashboards/qdev_user_data.json
index ff55b9ff8..d80d57bab 100644
--- a/grafana/dashboards/qdev_user_data.json
+++ b/grafana/dashboards/qdev_user_data.json
@@ -789,7 +789,7 @@
   },
   "timepicker": {},
   "timezone": "utc",
-  "title": "Q Dev User Data Dashboard",
+  "title": "Kiro Code Metrics Dashboard",
   "uid": "qdev_user_data",
   "version": 1
 }
\ No newline at end of file
diff --git a/grafana/dashboards/qdev_user_report.json 
b/grafana/dashboards/qdev_user_report.json
new file mode 100644
index 000000000..e1a27bc53
--- /dev/null
+++ b/grafana/dashboards/qdev_user_report.json
@@ -0,0 +1,464 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "editable": true,
+  "fiscalYearStartMonth": 0,
+  "graphTooltip": 0,
+  "id": 101,
+  "links": [],
+  "panels": [
+    {
+      "datasource": "mysql",
+      "description": "Overview of credits and usage metrics",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "mappings": [],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green"
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 6,
+        "w": 24,
+        "x": 0,
+        "y": 0
+      },
+      "id": 1,
+      "options": {
+        "colorMode": "value",
+        "graphMode": "area",
+        "justifyMode": "auto",
+        "orientation": "auto",
+        "percentChangeColorMode": "standard",
+        "reduceOptions": {
+          "calcs": [
+            "sum"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "showPercentChange": false,
+        "text": {},
+        "textMode": "auto",
+        "wideLayout": true
+      },
+      "pluginVersion": "11.6.2",
+      "targets": [
+        {
+          "datasource": "mysql",
+          "editorMode": "code",
+          "format": "table",
+          "rawQuery": true,
+          "rawSql": "SELECT\n  SUM(credits_used) as 'Total Credits Used',\n  
COUNT(DISTINCT user_id) as 'Active Users',\n  SUM(total_messages) as 'Total 
Messages',\n  SUM(chat_conversations) as 'Total Conversations'\nFROM 
lake._tool_q_dev_user_report\nWHERE $__timeFilter(date)",
+          "refId": "A"
+        }
+      ],
+      "title": "Overview Stats",
+      "type": "stat"
+    },
+    {
+      "datasource": "mysql",
+      "description": "Daily credits consumed broken down by subscription tier",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "palette-classic"
+          },
+          "custom": {
+            "axisBorderShow": false,
+            "axisCenteredZero": false,
+            "axisColorMode": "text",
+            "axisLabel": "",
+            "axisPlacement": "auto",
+            "barAlignment": 0,
+            "barWidthFactor": 0.6,
+            "drawStyle": "line",
+            "fillOpacity": 10,
+            "gradientMode": "none",
+            "hideFrom": {
+              "legend": false,
+              "tooltip": false,
+              "viz": false
+            },
+            "insertNulls": false,
+            "lineInterpolation": "smooth",
+            "lineWidth": 2,
+            "pointSize": 5,
+            "scaleDistribution": {
+              "type": "linear"
+            },
+            "showPoints": "never",
+            "spanNulls": true,
+            "stacking": {
+              "group": "A",
+              "mode": "normal"
+            },
+            "thresholdsStyle": {
+              "mode": "off"
+            }
+          },
+          "mappings": [],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green"
+              }
+            ]
+          },
+          "unit": "short"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 8,
+        "w": 24,
+        "x": 0,
+        "y": 6
+      },
+      "id": 2,
+      "options": {
+        "legend": {
+          "calcs": [
+            "mean",
+            "max",
+            "sum"
+          ],
+          "displayMode": "table",
+          "placement": "right",
+          "showLegend": true
+        },
+        "tooltip": {
+          "hideZeros": false,
+          "mode": "multi",
+          "sort": "none"
+        }
+      },
+      "pluginVersion": "11.6.2",
+      "targets": [
+        {
+          "datasource": "mysql",
+          "editorMode": "code",
+          "format": "time_series",
+          "rawQuery": true,
+          "rawSql": "SELECT\n  date as time,\n  subscription_tier as metric,\n 
 SUM(credits_used) as value\nFROM lake._tool_q_dev_user_report\nWHERE 
$__timeFilter(date)\nGROUP BY date, subscription_tier\nORDER BY date",
+          "refId": "A"
+        }
+      ],
+      "title": "Daily Credits Consumed by Subscription Tier",
+      "type": "timeseries"
+    },
+    {
+      "datasource": "mysql",
+      "description": "Daily messages and conversations broken down by client 
type",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "palette-classic"
+          },
+          "custom": {
+            "axisBorderShow": false,
+            "axisCenteredZero": false,
+            "axisColorMode": "text",
+            "axisLabel": "",
+            "axisPlacement": "auto",
+            "barAlignment": 0,
+            "barWidthFactor": 0.6,
+            "drawStyle": "line",
+            "fillOpacity": 10,
+            "gradientMode": "none",
+            "hideFrom": {
+              "legend": false,
+              "tooltip": false,
+              "viz": false
+            },
+            "insertNulls": false,
+            "lineInterpolation": "smooth",
+            "lineWidth": 2,
+            "pointSize": 5,
+            "scaleDistribution": {
+              "type": "linear"
+            },
+            "showPoints": "never",
+            "spanNulls": true,
+            "stacking": {
+              "group": "A",
+              "mode": "none"
+            },
+            "thresholdsStyle": {
+              "mode": "off"
+            }
+          },
+          "mappings": [],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green"
+              }
+            ]
+          },
+          "unit": "short"
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 0,
+        "y": 14
+      },
+      "id": 3,
+      "options": {
+        "legend": {
+          "calcs": [
+            "mean",
+            "max",
+            "sum"
+          ],
+          "displayMode": "table",
+          "placement": "bottom",
+          "showLegend": true
+        },
+        "tooltip": {
+          "hideZeros": false,
+          "mode": "multi",
+          "sort": "none"
+        }
+      },
+      "pluginVersion": "11.6.2",
+      "targets": [
+        {
+          "datasource": "mysql",
+          "editorMode": "code",
+          "format": "time_series",
+          "rawQuery": true,
+          "rawSql": "SELECT\n  date as time,\n  SUM(CASE WHEN client_type = 
'KIRO_IDE' THEN total_messages ELSE 0 END) as 'Messages (IDE)',\n  SUM(CASE 
WHEN client_type = 'KIRO_CLI' THEN total_messages ELSE 0 END) as 'Messages 
(CLI)',\n  SUM(CASE WHEN client_type = 'PLUGIN' THEN total_messages ELSE 0 END) 
as 'Messages (Plugin)',\n  SUM(chat_conversations) as 'Conversations'\nFROM 
lake._tool_q_dev_user_report\nWHERE $__timeFilter(date)\nGROUP BY date\nORDER 
BY date",
+          "refId": "A"
+        }
+      ],
+      "title": "Daily Messages & Conversations",
+      "type": "timeseries"
+    },
+    {
+      "datasource": "mysql",
+      "description": "Distribution of users across subscription tiers",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "palette-classic"
+          },
+          "mappings": [],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green"
+              }
+            ]
+          }
+        },
+        "overrides": []
+      },
+      "gridPos": {
+        "h": 8,
+        "w": 12,
+        "x": 12,
+        "y": 14
+      },
+      "id": 4,
+      "options": {
+        "displayLabels": [
+          "name",
+          "percent"
+        ],
+        "legend": {
+          "displayMode": "table",
+          "placement": "right",
+          "showLegend": true,
+          "values": [
+            "value",
+            "percent"
+          ]
+        },
+        "pieType": "pie",
+        "reduceOptions": {
+          "calcs": [
+            "sum"
+          ],
+          "fields": "",
+          "values": false
+        },
+        "tooltip": {
+          "mode": "single",
+          "sort": "none"
+        }
+      },
+      "pluginVersion": "11.6.2",
+      "targets": [
+        {
+          "datasource": "mysql",
+          "editorMode": "code",
+          "format": "table",
+          "rawQuery": true,
+          "rawSql": "SELECT\n  subscription_tier as 'Tier',\n  COUNT(DISTINCT 
user_id) as 'Users'\nFROM lake._tool_q_dev_user_report\nWHERE 
$__timeFilter(date)\n  AND subscription_tier IS NOT NULL\n  AND 
subscription_tier != ''\nGROUP BY subscription_tier\nORDER BY COUNT(DISTINCT 
user_id) DESC",
+          "refId": "A"
+        }
+      ],
+      "title": "Subscription Tier Distribution",
+      "type": "piechart"
+    },
+    {
+      "datasource": "mysql",
+      "description": "Per-user credits, messages, and subscription details",
+      "fieldConfig": {
+        "defaults": {
+          "color": {
+            "mode": "thresholds"
+          },
+          "custom": {
+            "align": "auto",
+            "cellOptions": {
+              "type": "auto"
+            },
+            "filterable": true,
+            "inspect": false
+          },
+          "mappings": [],
+          "thresholds": {
+            "mode": "absolute",
+            "steps": [
+              {
+                "color": "green"
+              }
+            ]
+          }
+        },
+        "overrides": [
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Credits Used"
+            },
+            "properties": [
+              {
+                "id": "custom.cellOptions",
+                "value": {
+                  "mode": "gradient",
+                  "type": "gauge"
+                }
+              }
+            ]
+          },
+          {
+            "matcher": {
+              "id": "byName",
+              "options": "Overage"
+            },
+            "properties": [
+              {
+                "id": "custom.cellOptions",
+                "value": {
+                  "mode": "basic",
+                  "type": "color-background"
+                }
+              },
+              {
+                "id": "mappings",
+                "value": [
+                  {
+                    "options": {
+                      "Yes": {
+                        "color": "orange",
+                        "index": 0
+                      },
+                      "No": {
+                        "color": "green",
+                        "index": 1
+                      }
+                    },
+                    "type": "value"
+                  }
+                ]
+              }
+            ]
+          }
+        ]
+      },
+      "gridPos": {
+        "h": 10,
+        "w": 24,
+        "x": 0,
+        "y": 22
+      },
+      "id": 5,
+      "options": {
+        "cellHeight": "sm",
+        "footer": {
+          "countRows": false,
+          "fields": "",
+          "reducer": [
+            "sum"
+          ],
+          "show": false
+        },
+        "showHeader": true,
+        "sortBy": []
+      },
+      "pluginVersion": "11.6.2",
+      "targets": [
+        {
+          "datasource": "mysql",
+          "editorMode": "code",
+          "format": "table",
+          "rawQuery": true,
+          "rawSql": "SELECT\n  COALESCE(display_name, user_id) as 'User',\n  
subscription_tier as 'Tier',\n  client_type as 'Client',\n  SUM(credits_used) 
as 'Credits Used',\n  SUM(total_messages) as 'Messages',\n  
SUM(chat_conversations) as 'Conversations',\n  SUM(overage_credits_used) as 
'Overage Credits',\n  CASE WHEN MAX(CAST(overage_enabled AS UNSIGNED)) = 1 THEN 
'Yes' ELSE 'No' END as 'Overage',\n  MIN(date) as 'First Activity',\n  
MAX(date) as 'Last Activity'\nFROM lake._tool_q_de [...]
+          "refId": "A"
+        }
+      ],
+      "title": "Per-User Credits & Activity",
+      "type": "table"
+    }
+  ],
+  "preload": false,
+  "refresh": "5m",
+  "schemaVersion": 41,
+  "tags": [
+    "q_dev",
+    "user_report",
+    "kiro"
+  ],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-30d",
+    "to": "now"
+  },
+  "timepicker": {},
+  "timezone": "utc",
+  "title": "Kiro Usage Dashboard",
+  "uid": "qdev_user_report",
+  "version": 1
+}

Reply via email to