Skip to content

Commit b1cf560

Browse files
authored
feat(q_dev): add Account ID to scope config and auto-construct both report paths (#8725)
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.
1 parent a34b8c4 commit b1cf560

File tree

17 files changed

+1203
-96
lines changed

17 files changed

+1203
-96
lines changed

backend/plugins/q_dev/api/blueprint_v200.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ func makeDataSourcePipelinePlanV200(
7272
ConnectionId: s3Slice.ConnectionId,
7373
S3Prefix: s3Slice.Prefix,
7474
ScopeId: s3Slice.Id,
75+
AccountId: s3Slice.AccountId,
76+
BasePath: s3Slice.BasePath,
77+
Year: s3Slice.Year,
78+
Month: s3Slice.Month,
7579
}
7680

7781
// Pass empty entities array to enable all subtasks

backend/plugins/q_dev/impl/impl.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ func (p QDev) GetTablesInfo() []dal.Tabler {
5757
&models.QDevUserData{},
5858
&models.QDevS3FileMeta{},
5959
&models.QDevS3Slice{},
60+
&models.QDevUserReport{},
6061
}
6162
}
6263

@@ -117,10 +118,30 @@ func (p QDev) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]int
117118
identityClient = nil
118119
}
119120

121+
// Resolve S3 prefixes to scan
122+
var s3Prefixes []string
123+
if op.AccountId != "" {
124+
// New-style scope: construct both report paths using region from connection
125+
region := connection.Region
126+
timePart := fmt.Sprintf("%04d", op.Year)
127+
if op.Month != nil {
128+
timePart = fmt.Sprintf("%04d/%02d", op.Year, *op.Month)
129+
}
130+
base := fmt.Sprintf("%s/AWSLogs/%s/KiroLogs", op.BasePath, op.AccountId)
131+
s3Prefixes = []string{
132+
fmt.Sprintf("%s/by_user_analytic/%s/%s", base, region, timePart),
133+
fmt.Sprintf("%s/user_report/%s/%s", base, region, timePart),
134+
}
135+
} else {
136+
// Legacy scope: use S3Prefix directly
137+
s3Prefixes = []string{op.S3Prefix}
138+
}
139+
120140
return &tasks.QDevTaskData{
121141
Options: &op,
122142
S3Client: s3Client,
123143
IdentityClient: identityClient,
144+
S3Prefixes: s3Prefixes,
124145
}, nil
125146
}
126147

backend/plugins/q_dev/impl/impl_test.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func TestQDev_BasicPluginMethods(t *testing.T) {
3434

3535
// Test table info
3636
tables := plugin.GetTablesInfo()
37-
assert.Len(t, tables, 4)
37+
assert.Len(t, tables, 5)
3838

3939
// Test subtask metas
4040
subtasks := plugin.SubTaskMetas()
@@ -48,7 +48,7 @@ func TestQDev_BasicPluginMethods(t *testing.T) {
4848
}
4949

5050
func TestQDev_TaskDataStructure(t *testing.T) {
51-
// Test that QDevTaskData has the expected structure
51+
// Test that QDevTaskData has the expected structure (legacy mode)
5252
taskData := &tasks.QDevTaskData{
5353
Options: &tasks.QDevOptions{
5454
ConnectionId: 1,
@@ -61,6 +61,7 @@ func TestQDev_TaskDataStructure(t *testing.T) {
6161
StoreId: "d-1234567890",
6262
Region: "us-west-2",
6363
},
64+
S3Prefixes: []string{"test/"},
6465
}
6566

6667
assert.NotNil(t, taskData.Options)
@@ -72,6 +73,36 @@ func TestQDev_TaskDataStructure(t *testing.T) {
7273
assert.Equal(t, "test-bucket", taskData.S3Client.Bucket)
7374
assert.Equal(t, "d-1234567890", taskData.IdentityClient.StoreId)
7475
assert.Equal(t, "us-west-2", taskData.IdentityClient.Region)
76+
assert.Equal(t, []string{"test/"}, taskData.S3Prefixes)
77+
}
78+
79+
func TestQDev_TaskDataWithAccountId(t *testing.T) {
80+
// Test new-style scope with AccountId and multiple S3Prefixes
81+
month := 1
82+
taskData := &tasks.QDevTaskData{
83+
Options: &tasks.QDevOptions{
84+
ConnectionId: 1,
85+
AccountId: "034362076319",
86+
BasePath: "user-report",
87+
Year: 2026,
88+
Month: &month,
89+
},
90+
S3Client: &tasks.QDevS3Client{
91+
Bucket: "test-bucket",
92+
},
93+
S3Prefixes: []string{
94+
"user-report/AWSLogs/034362076319/KiroLogs/by_user_analytic/us-east-1/2026/01",
95+
"user-report/AWSLogs/034362076319/KiroLogs/user_report/us-east-1/2026/01",
96+
},
97+
}
98+
99+
assert.Equal(t, "034362076319", taskData.Options.AccountId)
100+
assert.Equal(t, "user-report", taskData.Options.BasePath)
101+
assert.Equal(t, 2026, taskData.Options.Year)
102+
assert.Equal(t, &month, taskData.Options.Month)
103+
assert.Len(t, taskData.S3Prefixes, 2)
104+
assert.Contains(t, taskData.S3Prefixes[0], "by_user_analytic")
105+
assert.Contains(t, taskData.S3Prefixes[1], "user_report")
75106
}
76107

77108
func TestQDev_TaskDataWithoutIdentityClient(t *testing.T) {
@@ -83,10 +114,12 @@ func TestQDev_TaskDataWithoutIdentityClient(t *testing.T) {
83114
S3Client: &tasks.QDevS3Client{
84115
Bucket: "test-bucket",
85116
},
86-
IdentityClient: nil, // No identity client
117+
IdentityClient: nil,
118+
S3Prefixes: []string{"some-prefix/"},
87119
}
88120

89121
assert.NotNil(t, taskData.Options)
90122
assert.NotNil(t, taskData.S3Client)
91123
assert.Nil(t, taskData.IdentityClient)
124+
assert.Len(t, taskData.S3Prefixes, 1)
92125
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package migrationscripts
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/context"
22+
"github.com/apache/incubator-devlake/core/errors"
23+
"github.com/apache/incubator-devlake/helpers/migrationhelper"
24+
"github.com/apache/incubator-devlake/plugins/q_dev/models/migrationscripts/archived"
25+
)
26+
27+
type addUserReportTable struct{}
28+
29+
func (*addUserReportTable) Up(basicRes context.BasicRes) errors.Error {
30+
return migrationhelper.AutoMigrateTables(
31+
basicRes,
32+
&archived.QDevUserReport{},
33+
)
34+
}
35+
36+
func (*addUserReportTable) Version() uint64 {
37+
return 20260219000001
38+
}
39+
40+
func (*addUserReportTable) Name() string {
41+
return "Add user_report table for Kiro credits/subscription metrics"
42+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package migrationscripts
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/context"
22+
"github.com/apache/incubator-devlake/core/errors"
23+
"github.com/apache/incubator-devlake/core/plugin"
24+
)
25+
26+
var _ plugin.MigrationScript = (*addAccountIdToS3Slice)(nil)
27+
28+
type addAccountIdToS3Slice struct{}
29+
30+
func (*addAccountIdToS3Slice) Up(basicRes context.BasicRes) errors.Error {
31+
db := basicRes.GetDal()
32+
33+
err := db.Exec(`
34+
ALTER TABLE _tool_q_dev_s3_slices
35+
ADD COLUMN IF NOT EXISTS account_id VARCHAR(255) DEFAULT NULL
36+
`)
37+
if err != nil {
38+
// Try alternative syntax for databases that don't support IF NOT EXISTS
39+
_ = db.Exec(`ALTER TABLE _tool_q_dev_s3_slices ADD COLUMN account_id VARCHAR(255) DEFAULT NULL`)
40+
}
41+
42+
return nil
43+
}
44+
45+
func (*addAccountIdToS3Slice) Version() uint64 {
46+
return 20260220000001
47+
}
48+
49+
func (*addAccountIdToS3Slice) Name() string {
50+
return "add account_id column to _tool_q_dev_s3_slices for auto-constructing S3 prefixes"
51+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package archived
19+
20+
import (
21+
"time"
22+
23+
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
24+
)
25+
26+
type QDevUserReport struct {
27+
archived.Model
28+
ConnectionId uint64 `gorm:"primaryKey"`
29+
UserId string `gorm:"index" json:"userId"`
30+
Date time.Time `gorm:"index" json:"date"`
31+
DisplayName string `gorm:"type:varchar(255)" json:"displayName"`
32+
ScopeId string `gorm:"index;type:varchar(255)" json:"scopeId"`
33+
ClientType string `gorm:"type:varchar(50)" json:"clientType"`
34+
SubscriptionTier string `gorm:"type:varchar(50)" json:"subscriptionTier"`
35+
ProfileId string `gorm:"type:varchar(512)" json:"profileId"`
36+
ChatConversations int `json:"chatConversations"`
37+
CreditsUsed float64 `json:"creditsUsed"`
38+
OverageCap float64 `json:"overageCap"`
39+
OverageCreditsUsed float64 `json:"overageCreditsUsed"`
40+
OverageEnabled bool `json:"overageEnabled"`
41+
TotalMessages int `json:"totalMessages"`
42+
}
43+
44+
func (QDevUserReport) TableName() string {
45+
return "_tool_q_dev_user_report"
46+
}

backend/plugins/q_dev/models/migrationscripts/register.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,7 @@ func All() []plugin.MigrationScript {
3131
new(addS3SliceTable),
3232
new(addScopeConfigIdToS3Slice),
3333
new(addScopeIdFields),
34+
new(addUserReportTable),
35+
new(addAccountIdToS3Slice),
3436
}
3537
}

backend/plugins/q_dev/models/s3_slice.go

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type QDevS3Slice struct {
3333
Id string `json:"id" mapstructure:"id" gorm:"primaryKey;type:varchar(512)"`
3434
Prefix string `json:"prefix" mapstructure:"prefix" gorm:"type:varchar(512);not null"`
3535
BasePath string `json:"basePath" mapstructure:"basePath" gorm:"type:varchar(512)"`
36+
AccountId string `json:"accountId,omitempty" mapstructure:"accountId" gorm:"type:varchar(255)"`
3637
Year int `json:"year" mapstructure:"year" gorm:"not null"`
3738
Month *int `json:"month,omitempty" mapstructure:"month"`
3839

@@ -61,6 +62,7 @@ func (s *QDevS3Slice) normalize(strict bool) error {
6162
}
6263

6364
s.BasePath = cleanPath(s.BasePath)
65+
s.AccountId = strings.TrimSpace(s.AccountId)
6466
s.Prefix = cleanPath(selectNonEmpty(s.Prefix, s.Id))
6567

6668
if s.Year <= 0 {
@@ -81,23 +83,37 @@ func (s *QDevS3Slice) normalize(strict bool) error {
8183
}
8284
}
8385

84-
if s.Prefix == "" {
85-
s.Prefix = buildPrefix(s.BasePath, s.Year, s.Month)
86-
}
86+
if s.AccountId != "" {
87+
// New-style scope: construct a logical identifier from component parts
88+
s.Prefix = buildPrefixWithAccount(s.BasePath, s.AccountId, s.Year, s.Month)
89+
} else {
90+
// Legacy scope: derive prefix from basePath + year + month
91+
if s.Prefix == "" {
92+
s.Prefix = buildPrefix(s.BasePath, s.Year, s.Month)
93+
}
8794

88-
prefix := buildPrefix(s.BasePath, s.Year, s.Month)
89-
if prefix != "" {
90-
s.Prefix = prefix
95+
prefix := buildPrefix(s.BasePath, s.Year, s.Month)
96+
if prefix != "" {
97+
s.Prefix = prefix
98+
}
9199
}
92100

93101
if s.Id == "" {
94102
s.Id = s.Prefix
95103
}
96104

97-
if s.Month != nil {
98-
s.Name = fmt.Sprintf("%04d-%02d", s.Year, *s.Month)
99-
} else if s.Year > 0 {
100-
s.Name = fmt.Sprintf("%04d", s.Year)
105+
if s.AccountId != "" {
106+
if s.Month != nil {
107+
s.Name = fmt.Sprintf("%s %04d-%02d", s.AccountId, s.Year, *s.Month)
108+
} else if s.Year > 0 {
109+
s.Name = fmt.Sprintf("%s %04d", s.AccountId, s.Year)
110+
}
111+
} else {
112+
if s.Month != nil {
113+
s.Name = fmt.Sprintf("%04d-%02d", s.Year, *s.Month)
114+
} else if s.Year > 0 {
115+
s.Name = fmt.Sprintf("%04d", s.Year)
116+
}
101117
}
102118

103119
if s.FullName == "" {
@@ -150,6 +166,14 @@ func (s QDevS3Slice) ScopeName() string {
150166
if s.Name != "" {
151167
return s.Name
152168
}
169+
if s.AccountId != "" {
170+
if s.Month != nil {
171+
return fmt.Sprintf("%s %04d-%02d", s.AccountId, s.Year, *s.Month)
172+
}
173+
if s.Year > 0 {
174+
return fmt.Sprintf("%s %04d", s.AccountId, s.Year)
175+
}
176+
}
153177
if s.Month != nil {
154178
return fmt.Sprintf("%04d-%02d", s.Year, *s.Month)
155179
}
@@ -186,6 +210,20 @@ type QDevS3SliceParams struct {
186210

187211
var _ plugin.ToolLayerScope = (*QDevS3Slice)(nil)
188212

213+
func buildPrefixWithAccount(basePath string, accountId string, year int, month *int) string {
214+
parts := splitPath(basePath)
215+
if accountId != "" {
216+
parts = append(parts, accountId)
217+
}
218+
if year > 0 {
219+
parts = append(parts, fmt.Sprintf("%04d", year))
220+
}
221+
if month != nil {
222+
parts = append(parts, fmt.Sprintf("%02d", *month))
223+
}
224+
return strings.Join(parts, "/")
225+
}
226+
189227
func buildPrefix(basePath string, year int, month *int) string {
190228
parts := splitPath(basePath)
191229
if year > 0 {

0 commit comments

Comments
 (0)