This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 065206eed feat(kiro): add core models and report parser (#9073)
065206eed is described below
commit 065206eedfbf232457fd6f3b6ec4f344724c57bb
Author: Warren Chen <[email protected]>
AuthorDate: Thu Aug 27 18:57:26 2026 +0800
feat(kiro): add core models and report parser (#9073)
Signed-off-by: warren <[email protected]>
---
backend/plugins/kiro/models/chat_log.go | 86 ++++++
backend/plugins/kiro/models/completion_log.go | 63 +++++
backend/plugins/kiro/models/connection.go | 139 +++++++++
backend/plugins/kiro/models/connection_test.go | 89 ++++++
.../kiro/models/migrationscripts/20260729_init.go | 48 ++++
.../kiro/models/migrationscripts/archived/init.go | 168 +++++++++++
.../kiro/models/migrationscripts/register.go | 30 ++
backend/plugins/kiro/models/s3_file_meta.go | 75 +++++
backend/plugins/kiro/models/s3_slice.go | 164 +++++++++++
backend/plugins/kiro/models/s3_slice_test.go | 110 ++++++++
backend/plugins/kiro/models/user_model_messages.go | 57 ++++
backend/plugins/kiro/models/user_report.go | 78 ++++++
backend/plugins/kiro/tasks/normalize.go | 171 +++++++++++
backend/plugins/kiro/tasks/normalize_test.go | 273 ++++++++++++++++++
.../01_earliest_no_email_no_newuser.csv | 2 +
.../tasks/testdata/user_report/02_standard_cli.csv | 2 +
.../tasks/testdata/user_report/03_standard_ide.csv | 2 +
.../user_report/04_newuser_after_models.csv | 2 +
.../user_report/05_prefixed_userid_upper_bool.csv | 2 +
.../tasks/testdata/user_report/06_two_users.csv | 3 +
.../user_report/07_kiro_web_nonzero_overage.csv | 2 +
.../testdata/user_report/08_plugin_multi_model.csv | 2 +
backend/plugins/kiro/tasks/user_report_parser.go | 294 +++++++++++++++++++
.../plugins/kiro/tasks/user_report_parser_test.go | 312 +++++++++++++++++++++
24 files changed, 2174 insertions(+)
diff --git a/backend/plugins/kiro/models/chat_log.go
b/backend/plugins/kiro/models/chat_log.go
new file mode 100644
index 000000000..ecd943696
--- /dev/null
+++ b/backend/plugins/kiro/models/chat_log.go
@@ -0,0 +1,86 @@
+/*
+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"
+)
+
+// KiroChatLog is one GenerateAssistantResponse interaction.
+//
+// Neither the prompt nor the assistant response text is stored. Derived
+// features are computed from them during extraction and the originals are
+// discarded, which keeps proprietary code and personal content out of the
+// warehouse while preserving the signal.
+//
+// RequestId is the key rather than (UserId, Timestamp) because log timestamps
+// carry nanosecond precision that MySQL DATETIME(6) truncates - two records
+// within the same microsecond would collide.
+type KiroChatLog struct {
+ common.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ RequestId string `gorm:"primaryKey;type:varchar(64)"`
+
+ // UserId is the bare UUID; log records always carry an identity-store
+ // prefix, which is stripped so this joins against the report table.
+ UserId string `gorm:"type:varchar(64);index" json:"userId"`
+ // IdentityStoreId is the stripped prefix, retained for auditability.
+ IdentityStoreId string `gorm:"type:varchar(32)"
json:"identityStoreId"`
+ Timestamp time.Time `gorm:"type:datetime(6);index"
json:"timestamp"`
+ // ChatTriggerType is MANUAL or INLINE_CHAT per the docs; only MANUAL
has
+ // been observed. Not validated against a fixed set.
+ ChatTriggerType string `gorm:"type:varchar(20)" json:"chatTriggerType"`
+ // ModelId is present on only about 45% of records, hence nullable. Do
not
+ // use it for model attribution; see KiroUserModelMessage.
+ ModelId *string `gorm:"type:varchar(100)" json:"modelId"`
+
+ // HasPrompt distinguishes a user turn from an agent self-continuation.
+ //
+ // An empty prompt means the agent continued on its own after a tool
call;
+ // a non-empty one means the user spoke. The ratio between them
measures how
+ // much of the traffic the agent generates versus the human - roughly
71% of
+ // sampled records have no prompt.
+ HasPrompt bool `gorm:"index" json:"hasPrompt"`
+ PromptLength int `json:"promptLength"`
+ // PromptSha256 identifies the same prompt being resubmitted, which is a
+ // rework signal. NULL when the prompt is empty: hashing the empty
string
+ // would give ~71% of rows one shared hash and destroy the signal.
+ PromptSha256 *string `gorm:"type:char(64);index" json:"promptSha256"`
+
+ ResponseLength int `json:"responseLength"`
+ HasFollowupPrompts bool `json:"hasFollowupPrompts"`
+
+ // HasSteering and IsSpecMode are heuristics derived from prompt text,
so
+ // they are meaningful only when HasPrompt is true and NULL otherwise -
+ // storing false would be indistinguishable from a real negative.
+ HasSteering *bool `json:"hasSteering"`
+ IsSpecMode *bool `json:"isSpecMode"`
+
+ // ConversationId and UtteranceId are documented but absent from every
+ // sampled record. Kept as nullable columns in case they appear later;
their
+ // absence is why the S3 logs cannot group interactions into sessions.
+ ConversationId *string `gorm:"type:varchar(64)" json:"conversationId"`
+ UtteranceId *string `gorm:"type:varchar(64)" json:"utteranceId"`
+}
+
+func (KiroChatLog) TableName() string {
+ return "_tool_kiro_chat_log"
+}
diff --git a/backend/plugins/kiro/models/completion_log.go
b/backend/plugins/kiro/models/completion_log.go
new file mode 100644
index 000000000..0d09c28e0
--- /dev/null
+++ b/backend/plugins/kiro/models/completion_log.go
@@ -0,0 +1,63 @@
+/*
+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"
+)
+
+// KiroCompletionLog is one GenerateCompletions (inline suggestion) request.
+//
+// The counters are named Returned*, not Accepted*: a record is written when
the
+// suggestion is requested, not when it is accepted. Empty completions arrays
are
+// common, so these numbers measure what Kiro offered rather than what the
+// developer took.
+//
+// Note also that FileName is a bare file name with no directory path, so it
+// cannot be resolved to a unique repository file.
+type KiroCompletionLog struct {
+ common.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ RequestId string `gorm:"primaryKey;type:varchar(64)"`
+
+ UserId string `gorm:"type:varchar(64);index" json:"userId"`
+ IdentityStoreId string `gorm:"type:varchar(32)"
json:"identityStoreId"`
+ Timestamp time.Time `gorm:"type:datetime(6);index"
json:"timestamp"`
+
+ // FileName has no path component; see the type comment.
+ FileName string `gorm:"type:varchar(255);index" json:"fileName"`
+ FileExtension string `gorm:"type:varchar(50)" json:"fileExtension"`
+ // HasCustomization records whether a customization ARN was attached.
Present
+ // on completion records but never on chat records.
+ HasCustomization bool `json:"hasCustomization"`
+
+ // CompletionsCount is often 0 - the request was logged, no suggestion
came
+ // back. Such records are still stored.
+ CompletionsCount int `json:"completionsCount"`
+ ReturnedCharCount int `json:"returnedCharCount"`
+ ReturnedLineCount int `json:"returnedLineCount"`
+ LeftContextLength int `json:"leftContextLength"`
+ RightContextLength int `json:"rightContextLength"`
+}
+
+func (KiroCompletionLog) TableName() string {
+ return "_tool_kiro_completion_log"
+}
diff --git a/backend/plugins/kiro/models/connection.go
b/backend/plugins/kiro/models/connection.go
new file mode 100644
index 000000000..d8479c99b
--- /dev/null
+++ b/backend/plugins/kiro/models/connection.go
@@ -0,0 +1,139 @@
+/*
+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/utils"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+// Default S3 prefixes. Kiro's console writes user activity reports and
+// interaction logs under separate prefixes, and recommends (but does not
+// require) separate buckets for the two.
+const (
+ DefaultReportPrefix = "user-report"
+ DefaultPromptLogPrefix = "logging"
+)
+
+// KiroConn holds the essential information to connect to the AWS S3 buckets
+// that Kiro exports to.
+type KiroConn struct {
+ // AccessKeyId for AWS
+ AccessKeyId string `mapstructure:"accessKeyId" json:"accessKeyId"`
+ // SecretAccessKey for AWS
+ SecretAccessKey string `mapstructure:"secretAccessKey"
json:"secretAccessKey"`
+ // Region of the buckets, and of the Kiro profile that writes to them
+ Region string `mapstructure:"region" json:"region"`
+
+ // Bucket holding the user activity report CSVs
+ Bucket string `mapstructure:"bucket" json:"bucket"`
+ // ReportPrefix within Bucket; defaults to DefaultReportPrefix when
empty
+ ReportPrefix string `mapstructure:"reportPrefix" json:"reportPrefix"`
+
+ // PromptLogBucket holding the interaction logs. Kiro recommends a
bucket
+ // separate from the report one; when empty this falls back to Bucket so
+ // that single-bucket and dual-bucket setups share one code path.
+ PromptLogBucket string `mapstructure:"promptLogBucket"
json:"promptLogBucket"`
+ // PromptLogPrefix within PromptLogBucket; defaults to
+ // DefaultPromptLogPrefix when empty
+ PromptLogPrefix string `mapstructure:"promptLogPrefix"
json:"promptLogPrefix"`
+
+ // IdentityStoreId for AWS IAM Identity Center. Optional: it only
resolves
+ // display names. User identity is keyed on the User_Email column of the
+ // report CSV, so collection works fully without it.
+ IdentityStoreId string `mapstructure:"identityStoreId"
json:"identityStoreId"`
+ // IdentityStoreRegion may differ from the S3 region. Optional.
+ IdentityStoreRegion string `mapstructure:"identityStoreRegion"
json:"identityStoreRegion"`
+
+ // RateLimitPerHour limits the requests sent to AWS
+ RateLimitPerHour int `mapstructure:"rateLimitPerHour"
json:"rateLimitPerHour"`
+}
+
+// GetReportPrefix returns the report prefix with its default applied.
+func (conn *KiroConn) GetReportPrefix() string {
+ if p := strings.Trim(strings.TrimSpace(conn.ReportPrefix), "/"); p !=
"" {
+ return p
+ }
+ return DefaultReportPrefix
+}
+
+// GetPromptLogBucket returns the interaction log bucket, falling back to the
+// report bucket when no separate one is configured.
+func (conn *KiroConn) GetPromptLogBucket() string {
+ if b := strings.TrimSpace(conn.PromptLogBucket); b != "" {
+ return b
+ }
+ return strings.TrimSpace(conn.Bucket)
+}
+
+// GetPromptLogPrefix returns the interaction log prefix with its default
+// applied.
+func (conn *KiroConn) GetPromptLogPrefix() string {
+ if p := strings.Trim(strings.TrimSpace(conn.PromptLogPrefix), "/"); p
!= "" {
+ return p
+ }
+ return DefaultPromptLogPrefix
+}
+
+// UsesSeparateBuckets reports whether reports and logs live in different
+// buckets, which determines whether both need an access check.
+func (conn *KiroConn) UsesSeparateBuckets() bool {
+ return conn.GetPromptLogBucket() != strings.TrimSpace(conn.Bucket)
+}
+
+func (conn *KiroConn) Sanitize() KiroConn {
+ conn.SecretAccessKey = utils.SanitizeString(conn.SecretAccessKey)
+ return *conn
+}
+
+// Deliberately no GetEndpoint/GetProxy/GetRateLimitPerHour here.
+//
+// Implementing plugin.ApiConnection would let this type flow into DevLake's
+// shared scope-browsing helpers, but those construct an HTTP client from the
+// endpoint and run a DNS check on it - and a bucket name is not a hostname, so
+// the check fails. Rather than return a fake endpoint that looks dialable, the
+// plugin implements its scope endpoints directly against the AWS SDK.
+
+// KiroConnection holds KiroConn plus ID/Name for database storage
+type KiroConnection struct {
+ helper.BaseConnection `mapstructure:",squash"`
+ KiroConn `mapstructure:",squash"`
+}
+
+func (KiroConnection) TableName() string {
+ return "_tool_kiro_connections"
+}
+
+func (connection KiroConnection) Sanitize() KiroConnection {
+ connection.KiroConn = connection.KiroConn.Sanitize()
+ return connection
+}
+
+func (connection *KiroConnection) MergeFromRequest(target *KiroConnection,
body map[string]interface{}) error {
+ secretKey := target.SecretAccessKey
+ if err := helper.DecodeMapStruct(body, target, true); err != nil {
+ return err
+ }
+ modifiedSecretKey := target.SecretAccessKey
+ if modifiedSecretKey == "" || modifiedSecretKey ==
utils.SanitizeString(secretKey) {
+ target.SecretAccessKey = secretKey
+ }
+ return nil
+}
diff --git a/backend/plugins/kiro/models/connection_test.go
b/backend/plugins/kiro/models/connection_test.go
new file mode 100644
index 000000000..946edcee0
--- /dev/null
+++ b/backend/plugins/kiro/models/connection_test.go
@@ -0,0 +1,89 @@
+/*
+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"
+)
+
+// The fallback rules exist so that a single-bucket setup (what real exports
+// currently look like) and the dual-bucket layout Kiro recommends both work
+// without branching anywhere else in the plugin.
+func TestConnectionPrefixAndBucketFallback(t *testing.T) {
+ t.Run("single bucket with defaults", func(t *testing.T) {
+ conn := &KiroConn{Bucket: "kiro-export-test"}
+ assert.Equal(t, "user-report", conn.GetReportPrefix())
+ assert.Equal(t, "logging", conn.GetPromptLogPrefix())
+ assert.Equal(t, "kiro-export-test", conn.GetPromptLogBucket())
+ assert.False(t, conn.UsesSeparateBuckets())
+ })
+
+ t.Run("single bucket with explicit prefixes", func(t *testing.T) {
+ conn := &KiroConn{
+ Bucket: "kiro-export-test",
+ ReportPrefix: "user-report",
+ PromptLogPrefix: "logging",
+ }
+ assert.Equal(t, "user-report", conn.GetReportPrefix())
+ assert.Equal(t, "logging", conn.GetPromptLogPrefix())
+ assert.False(t, conn.UsesSeparateBuckets())
+ })
+
+ t.Run("separate buckets", func(t *testing.T) {
+ conn := &KiroConn{
+ Bucket: "reports-bucket",
+ PromptLogBucket: "logs-bucket",
+ }
+ assert.Equal(t, "reports-bucket", conn.Bucket)
+ assert.Equal(t, "logs-bucket", conn.GetPromptLogBucket())
+ assert.True(t, conn.UsesSeparateBuckets())
+ })
+
+ t.Run("prefixes are stripped of slashes and whitespace", func(t
*testing.T) {
+ conn := &KiroConn{
+ Bucket: " kiro-export-test ",
+ ReportPrefix: "/custom-report/",
+ PromptLogPrefix: " /custom-logs/ ",
+ }
+ assert.Equal(t, "custom-report", conn.GetReportPrefix())
+ assert.Equal(t, "custom-logs", conn.GetPromptLogPrefix())
+ // Whitespace-only difference must not read as two buckets.
+ assert.False(t, conn.UsesSeparateBuckets())
+ })
+
+ t.Run("whitespace-only prompt log bucket falls back", func(t
*testing.T) {
+ conn := &KiroConn{Bucket: "b", PromptLogBucket: " "}
+ assert.Equal(t, "b", conn.GetPromptLogBucket())
+ assert.False(t, conn.UsesSeparateBuckets())
+ })
+}
+
+func TestConnectionSanitize(t *testing.T) {
+ conn := KiroConnection{
+ KiroConn: KiroConn{
+ AccessKeyId: "AKIAEXAMPLE",
+ SecretAccessKey: "super-secret-value",
+ },
+ }
+ sanitized := conn.Sanitize()
+ assert.NotEqual(t, "super-secret-value", sanitized.SecretAccessKey)
+ // The key id is not a secret and stays readable for troubleshooting.
+ assert.Equal(t, "AKIAEXAMPLE", sanitized.AccessKeyId)
+}
diff --git a/backend/plugins/kiro/models/migrationscripts/20260729_init.go
b/backend/plugins/kiro/models/migrationscripts/20260729_init.go
new file mode 100644
index 000000000..4b2c8e7bb
--- /dev/null
+++ b/backend/plugins/kiro/models/migrationscripts/20260729_init.go
@@ -0,0 +1,48 @@
+/*
+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/helpers/migrationhelper"
+
"github.com/apache/incubator-devlake/plugins/kiro/models/migrationscripts/archived"
+)
+
+type initTables struct{}
+
+func (*initTables) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(
+ basicRes,
+ &archived.KiroConnection{},
+ &archived.KiroS3Slice{},
+ &archived.KiroS3FileMeta{},
+ &archived.KiroUserReport{},
+ &archived.KiroUserModelMessage{},
+ &archived.KiroChatLog{},
+ &archived.KiroCompletionLog{},
+ )
+}
+
+func (*initTables) Version() uint64 {
+ return 20260729000001
+}
+
+func (*initTables) Name() string {
+ return "Initialize schema for the kiro plugin"
+}
diff --git a/backend/plugins/kiro/models/migrationscripts/archived/init.go
b/backend/plugins/kiro/models/migrationscripts/archived/init.go
new file mode 100644
index 000000000..5c1c9a322
--- /dev/null
+++ b/backend/plugins/kiro/models/migrationscripts/archived/init.go
@@ -0,0 +1,168 @@
+/*
+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 holds frozen copies of the model structs as they were when
+// each migration was written. Migrations reference these instead of the live
+// models so that editing a live model never changes what a historical
migration
+// does.
+package archived
+
+import (
+ "time"
+
+
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+)
+
+type KiroConnection struct {
+ archived.Model
+ Name string `gorm:"type:varchar(100);uniqueIndex"
json:"name"`
+ AccessKeyId string `gorm:"type:varchar(255)" json:"accessKeyId"`
+ SecretAccessKey string `gorm:"type:varchar(255)"
json:"secretAccessKey"`
+ Region string `gorm:"type:varchar(100)" json:"region"`
+ Bucket string `gorm:"type:varchar(255)" json:"bucket"`
+ ReportPrefix string `gorm:"type:varchar(512)"
json:"reportPrefix"`
+ PromptLogBucket string `gorm:"type:varchar(255)"
json:"promptLogBucket"`
+ PromptLogPrefix string `gorm:"type:varchar(512)"
json:"promptLogPrefix"`
+ IdentityStoreId string `gorm:"type:varchar(255)"
json:"identityStoreId"`
+ IdentityStoreRegion string `gorm:"type:varchar(100)"
json:"identityStoreRegion"`
+ RateLimitPerHour int `json:"rateLimitPerHour"`
+}
+
+func (KiroConnection) TableName() string {
+ return "_tool_kiro_connections"
+}
+
+type KiroS3Slice struct {
+ archived.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeConfigId uint64 `json:"scopeConfigId,omitempty"`
+ Id string `gorm:"primaryKey;type:varchar(512)" json:"id"`
+ BasePath string `gorm:"type:varchar(512)" json:"basePath"`
+ AccountId string `gorm:"type:varchar(255);not null"
json:"accountId"`
+ Year int `gorm:"not null" json:"year"`
+ Month *int `json:"month"`
+}
+
+func (KiroS3Slice) TableName() string {
+ return "_tool_kiro_s3_slices"
+}
+
+type KiroS3FileMeta struct {
+ archived.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ S3Path string `gorm:"primaryKey;type:varchar(512)"`
+ FileName string `gorm:"type:varchar(255)" json:"fileName"`
+ Bucket string `gorm:"type:varchar(255)" json:"bucket"`
+ ScopeId string `gorm:"type:varchar(255);index" json:"scopeId"`
+ FileType string `gorm:"type:varchar(32);index" json:"fileType"`
+ Processed bool `gorm:"default:false" json:"processed"`
+ ProcessedTime *time.Time `gorm:"default:null" json:"processedTime"`
+ RecordCount int `gorm:"default:0" json:"recordCount"`
+ ErrorMessage string `gorm:"type:text" json:"errorMessage"`
+ AttemptCount int `gorm:"default:0" json:"attemptCount"`
+}
+
+func (KiroS3FileMeta) TableName() string {
+ return "_tool_kiro_s3_file_meta"
+}
+
+type KiroUserReport struct {
+ archived.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ UserId string `gorm:"primaryKey;type:varchar(64)"`
+ Date time.Time `gorm:"primaryKey;type:date"`
+ ClientType string `gorm:"primaryKey;type:varchar(20)"`
+ IdentityStoreId string `gorm:"type:varchar(32)"
json:"identityStoreId"`
+ UserEmail *string `gorm:"type:varchar(255);index"
json:"userEmail"`
+ DisplayName *string `gorm:"type:varchar(255)"
json:"displayName"`
+ ProfileId string `gorm:"type:varchar(512)" json:"profileId"`
+ SubscriptionTier string `gorm:"type:varchar(50)"
json:"subscriptionTier"`
+ IsNewUser *bool `json:"isNewUser"`
+ ChatConversations int `json:"chatConversations"`
+ TotalMessages int `json:"totalMessages"`
+ CreditsUsed float64 `json:"creditsUsed"`
+ OverageCap float64 `json:"overageCap"`
+ OverageCreditsUsed float64 `json:"overageCreditsUsed"`
+ OverageEnabled bool `json:"overageEnabled"`
+}
+
+func (KiroUserReport) TableName() string {
+ return "_tool_kiro_user_report"
+}
+
+type KiroUserModelMessage struct {
+ archived.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ UserId string `gorm:"primaryKey;type:varchar(64)"`
+ Date time.Time `gorm:"primaryKey;type:date"`
+ ClientType string `gorm:"primaryKey;type:varchar(20)"`
+ ModelName string `gorm:"primaryKey;type:varchar(100)"`
+ MessageCount int `json:"messageCount"`
+}
+
+func (KiroUserModelMessage) TableName() string {
+ return "_tool_kiro_user_model_messages"
+}
+
+type KiroChatLog struct {
+ archived.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ RequestId string `gorm:"primaryKey;type:varchar(64)"`
+ UserId string `gorm:"type:varchar(64);index"
json:"userId"`
+ IdentityStoreId string `gorm:"type:varchar(32)"
json:"identityStoreId"`
+ Timestamp time.Time `gorm:"type:datetime(6);index"
json:"timestamp"`
+ ChatTriggerType string `gorm:"type:varchar(20)"
json:"chatTriggerType"`
+ ModelId *string `gorm:"type:varchar(100)" json:"modelId"`
+ HasPrompt bool `gorm:"index" json:"hasPrompt"`
+ PromptLength int `json:"promptLength"`
+ PromptSha256 *string `gorm:"type:char(64);index"
json:"promptSha256"`
+ ResponseLength int `json:"responseLength"`
+ HasFollowupPrompts bool `json:"hasFollowupPrompts"`
+ HasSteering *bool `json:"hasSteering"`
+ IsSpecMode *bool `json:"isSpecMode"`
+ ConversationId *string `gorm:"type:varchar(64)"
json:"conversationId"`
+ UtteranceId *string `gorm:"type:varchar(64)"
json:"utteranceId"`
+}
+
+func (KiroChatLog) TableName() string {
+ return "_tool_kiro_chat_log"
+}
+
+type KiroCompletionLog struct {
+ archived.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ RequestId string `gorm:"primaryKey;type:varchar(64)"`
+ UserId string `gorm:"type:varchar(64);index"
json:"userId"`
+ IdentityStoreId string `gorm:"type:varchar(32)"
json:"identityStoreId"`
+ Timestamp time.Time `gorm:"type:datetime(6);index"
json:"timestamp"`
+ FileName string `gorm:"type:varchar(255);index"
json:"fileName"`
+ FileExtension string `gorm:"type:varchar(50)"
json:"fileExtension"`
+ HasCustomization bool `json:"hasCustomization"`
+ CompletionsCount int `json:"completionsCount"`
+ ReturnedCharCount int `json:"returnedCharCount"`
+ ReturnedLineCount int `json:"returnedLineCount"`
+ LeftContextLength int `json:"leftContextLength"`
+ RightContextLength int `json:"rightContextLength"`
+}
+
+func (KiroCompletionLog) TableName() string {
+ return "_tool_kiro_completion_log"
+}
diff --git a/backend/plugins/kiro/models/migrationscripts/register.go
b/backend/plugins/kiro/models/migrationscripts/register.go
new file mode 100644
index 000000000..ff85e7572
--- /dev/null
+++ b/backend/plugins/kiro/models/migrationscripts/register.go
@@ -0,0 +1,30 @@
+/*
+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 all migration scripts. Every new script must be added here or it
+// will never run.
+func All() []plugin.MigrationScript {
+ return []plugin.MigrationScript{
+ new(initTables),
+ }
+}
diff --git a/backend/plugins/kiro/models/s3_file_meta.go
b/backend/plugins/kiro/models/s3_file_meta.go
new file mode 100644
index 000000000..baea17ad4
--- /dev/null
+++ b/backend/plugins/kiro/models/s3_file_meta.go
@@ -0,0 +1,75 @@
+/*
+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"
+)
+
+// File types, derived from the S3 prefix a file was found under. Storing the
+// type lets extractors select their input without re-parsing paths.
+const (
+ FileTypeReport = "report"
+ FileTypeChatLog = "chat_log"
+ FileTypeCompletionLog = "completion_log"
+)
+
+// MaxAttempts caps retries of a failing file.
+//
+// Without a cap, a permanently malformed object is retried on every run: the
+// log fills with the same error and the scope never reaches a finished state.
+const MaxAttempts = 3
+
+// KiroS3FileMeta is the incremental collection cursor - one row per S3 object.
+//
+// The primary key is (ConnectionId, S3Path), NOT the file name. Existence
+// checks query by full path, so the key must match: with the path unindexed,
+// each check degenerates into a scan of every row for the connection, and at a
+// few million rows the extractor simply never finishes. The failure mode is
"the
+// task doesn't complete" rather than an error, which makes it hard to
diagnose.
+type KiroS3FileMeta struct {
+ common.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ // S3Path is the full object key. 512 chars because real keys already
run
+ // to ~155 and the prefix is user-configurable.
+ S3Path string `gorm:"primaryKey;type:varchar(512)"`
+ // FileName is the basename, for display and log messages only.
+ FileName string `gorm:"type:varchar(255)" json:"fileName"`
+ // Bucket records which bucket the object came from, since reports and
logs
+ // may live in different ones.
+ Bucket string `gorm:"type:varchar(255)" json:"bucket"`
+ ScopeId string `gorm:"type:varchar(255);index" json:"scopeId"`
+ // FileType is one of the FileType* constants above.
+ FileType string `gorm:"type:varchar(32);index" json:"fileType"`
+ Processed bool `gorm:"default:false" json:"processed"`
+ ProcessedTime *time.Time `gorm:"default:null" json:"processedTime"`
+ // RecordCount is how many rows the file yielded. Together with
+ // ErrorMessage this turns "why is this month short?" from guesswork
into a
+ // single query.
+ RecordCount int `gorm:"default:0" json:"recordCount"`
+ // ErrorMessage holds the last parse failure reason, if any.
+ ErrorMessage string `gorm:"type:text" json:"errorMessage"`
+ // AttemptCount guards against infinite retries; see MaxAttempts.
+ AttemptCount int `gorm:"default:0" json:"attemptCount"`
+}
+
+func (KiroS3FileMeta) TableName() string {
+ return "_tool_kiro_s3_file_meta"
+}
diff --git a/backend/plugins/kiro/models/s3_slice.go
b/backend/plugins/kiro/models/s3_slice.go
new file mode 100644
index 000000000..adffb0252
--- /dev/null
+++ b/backend/plugins/kiro/models/s3_slice.go
@@ -0,0 +1,164 @@
+/*
+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 (
+ "fmt"
+ "strings"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "gorm.io/gorm"
+)
+
+var _ plugin.ToolLayerScope = (*KiroS3Slice)(nil)
+
+// KiroS3Slice is a collection scope: one AWS account for one month (or one
+// whole year when Month is nil).
+//
+// The granularity mirrors Kiro's own S3 partitioning
+// ({region}/{year}/{month}/{day}/{hour}), so listing a scope touches only the
+// relevant prefixes instead of scanning the bucket. It also means a month can
+// be re-collected in isolation, which is how historical backfill works.
+//
+// Kiro requires a bucket per AWS account holding subscriptions and does not
+// support cross-account buckets, so AccountId belongs in the scope.
+type KiroS3Slice struct {
+ common.Scope `mapstructure:",squash"`
+ // Id is a URL-safe logical identifier: {accountId}_{year}[_{month}]
+ Id string `json:"id" mapstructure:"id"
gorm:"primaryKey;type:varchar(512)"`
+ // BasePath is an optional extra path segment before AWSLogs/
+ BasePath string `json:"basePath,omitempty" mapstructure:"basePath"
gorm:"type:varchar(512)"`
+ // AccountId is the AWS account that Kiro exports for
+ AccountId string `json:"accountId" mapstructure:"accountId"
gorm:"type:varchar(255);not null"`
+ Year int `json:"year" mapstructure:"year" gorm:"not null"`
+ // Month is nil to collect a whole year
+ Month *int `json:"month,omitempty" mapstructure:"month"`
+
+ Name string `json:"name" mapstructure:"name" gorm:"-"`
+}
+
+func (KiroS3Slice) TableName() string {
+ return "_tool_kiro_s3_slices"
+}
+
+// BeforeSave derives Id and validates required fields before persisting.
+func (s *KiroS3Slice) BeforeSave(_ *gorm.DB) error {
+ return s.normalize(true)
+}
+
+// AfterFind fills the derived display name for API responses.
+func (s *KiroS3Slice) AfterFind(_ *gorm.DB) error {
+ return s.normalize(false)
+}
+
+// normalize trims inputs and derives Id and Name.
+//
+// Unlike the predecessor scope this deliberately has no path-parsing
fallback: the
+// scope is always constructed from its component parts, never reverse-derived
+// from a raw prefix string.
+func (s *KiroS3Slice) normalize(strict bool) error {
+ if s == nil {
+ return nil
+ }
+
+ s.BasePath = strings.Trim(strings.TrimSpace(s.BasePath), "/")
+ s.AccountId = strings.TrimSpace(s.AccountId)
+
+ if strict {
+ if s.AccountId == "" {
+ return fmt.Errorf("accountId is required for a Kiro S3
slice")
+ }
+ if s.Year <= 0 {
+ return fmt.Errorf("year is required for a Kiro S3
slice")
+ }
+ }
+ if s.Month != nil && (*s.Month < 1 || *s.Month > 12) {
+ return fmt.Errorf("month must be between 1 and 12, got %d",
*s.Month)
+ }
+
+ if s.Id == "" && s.AccountId != "" && s.Year > 0 {
+ s.Id = s.buildId()
+ }
+ s.Name = s.buildName()
+ return nil
+}
+
+func (s *KiroS3Slice) buildId() string {
+ if s.Month != nil {
+ return fmt.Sprintf("%s_%04d_%02d", s.AccountId, s.Year,
*s.Month)
+ }
+ return fmt.Sprintf("%s_%04d", s.AccountId, s.Year)
+}
+
+func (s *KiroS3Slice) buildName() string {
+ if s.AccountId == "" || s.Year <= 0 {
+ return s.Id
+ }
+ if s.Month != nil {
+ return fmt.Sprintf("%s %04d-%02d", s.AccountId, s.Year,
*s.Month)
+ }
+ return fmt.Sprintf("%s %04d", s.AccountId, s.Year)
+}
+
+// TimePath renders the year/month portion of an S3 prefix. A nil Month yields
+// just the year, which widens collection to the whole year.
+func (s *KiroS3Slice) TimePath() string {
+ if s.Month != nil {
+ return fmt.Sprintf("%04d/%02d", s.Year, *s.Month)
+ }
+ return fmt.Sprintf("%04d", s.Year)
+}
+
+func (s KiroS3Slice) ScopeId() string {
+ return s.Id
+}
+
+func (s KiroS3Slice) ScopeName() string {
+ if s.Name != "" {
+ return s.Name
+ }
+ return s.buildName()
+}
+
+func (s KiroS3Slice) ScopeFullName() string {
+ return s.ScopeName()
+}
+
+func (s KiroS3Slice) ScopeParams() interface{} {
+ return &KiroS3SliceParams{
+ ConnectionId: s.ConnectionId,
+ AccountId: s.AccountId,
+ Year: s.Year,
+ Month: s.Month,
+ }
+}
+
+// Sanitize returns a copy ready for JSON serialization.
+func (s KiroS3Slice) Sanitize() KiroS3Slice {
+ _ = s.normalize(false)
+ return s
+}
+
+// KiroS3SliceParams identifies a scope in raw data records.
+type KiroS3SliceParams struct {
+ ConnectionId uint64
+ AccountId string
+ Year int
+ Month *int
+}
diff --git a/backend/plugins/kiro/models/s3_slice_test.go
b/backend/plugins/kiro/models/s3_slice_test.go
new file mode 100644
index 000000000..c200cc2f6
--- /dev/null
+++ b/backend/plugins/kiro/models/s3_slice_test.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 models
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func intPtr(i int) *int { return &i }
+
+func TestS3SliceNormalize(t *testing.T) {
+ t.Run("month scope derives id and name", func(t *testing.T) {
+ s := &KiroS3Slice{AccountId: "123456789012", Year: 2026, Month:
intPtr(7)}
+ assert.NoError(t, s.normalize(true))
+ assert.Equal(t, "123456789012_2026_07", s.Id)
+ assert.Equal(t, "123456789012 2026-07", s.Name)
+ assert.Equal(t, "2026/07", s.TimePath())
+ })
+
+ t.Run("year scope widens to the whole year", func(t *testing.T) {
+ s := &KiroS3Slice{AccountId: "123456789012", Year: 2026}
+ assert.NoError(t, s.normalize(true))
+ assert.Equal(t, "123456789012_2026", s.Id)
+ assert.Equal(t, "2026", s.TimePath())
+ })
+
+ t.Run("single digit month is zero padded", func(t *testing.T) {
+ s := &KiroS3Slice{AccountId: "acct", Year: 2026, Month:
intPtr(3)}
+ assert.NoError(t, s.normalize(true))
+ // Padding matters: Kiro's own S3 partitions are two-digit, so
an
+ // unpadded value would build a prefix that matches nothing.
+ assert.Equal(t, "acct_2026_03", s.Id)
+ assert.Equal(t, "2026/03", s.TimePath())
+ })
+
+ t.Run("explicit id is preserved", func(t *testing.T) {
+ s := &KiroS3Slice{Id: "custom-id", AccountId: "acct", Year:
2026}
+ assert.NoError(t, s.normalize(true))
+ assert.Equal(t, "custom-id", s.Id)
+ })
+
+ t.Run("base path is trimmed", func(t *testing.T) {
+ s := &KiroS3Slice{BasePath: " /nested/path/ ", AccountId:
"acct", Year: 2026}
+ assert.NoError(t, s.normalize(true))
+ assert.Equal(t, "nested/path", s.BasePath)
+ })
+}
+
+func TestS3SliceValidation(t *testing.T) {
+ t.Run("account id required in strict mode", func(t *testing.T) {
+ s := &KiroS3Slice{Year: 2026}
+ assert.Error(t, s.normalize(true))
+ })
+
+ t.Run("year required in strict mode", func(t *testing.T) {
+ s := &KiroS3Slice{AccountId: "acct"}
+ assert.Error(t, s.normalize(true))
+ })
+
+ // Non-strict mode runs on read, where rejecting an existing row would
make
+ // it unreadable rather than merely incomplete.
+ t.Run("missing fields tolerated in non-strict mode", func(t *testing.T)
{
+ s := &KiroS3Slice{}
+ assert.NoError(t, s.normalize(false))
+ })
+
+ for _, month := range []int{0, 13, -1} {
+ t.Run("invalid month rejected in both modes", func(t
*testing.T) {
+ s := &KiroS3Slice{AccountId: "acct", Year: 2026, Month:
intPtr(month)}
+ assert.Error(t, s.normalize(true))
+ // An out-of-range month cannot produce a valid prefix,
so it is
+ // rejected on read too rather than silently building a
bad path.
+ s2 := &KiroS3Slice{AccountId: "acct", Year: 2026,
Month: intPtr(month)}
+ assert.Error(t, s2.normalize(false))
+ })
+ }
+}
+
+func TestS3SliceScopeInterface(t *testing.T) {
+ s := KiroS3Slice{AccountId: "123456789012", Year: 2026, Month:
intPtr(7)}
+ assert.NoError(t, s.normalize(true))
+
+ assert.Equal(t, "123456789012_2026_07", s.ScopeId())
+ assert.Equal(t, "123456789012 2026-07", s.ScopeName())
+ assert.Equal(t, "123456789012 2026-07", s.ScopeFullName())
+ assert.Equal(t, "_tool_kiro_s3_slices", s.TableName())
+
+ params, ok := s.ScopeParams().(*KiroS3SliceParams)
+ assert.True(t, ok)
+ assert.Equal(t, "123456789012", params.AccountId)
+ assert.Equal(t, 2026, params.Year)
+ assert.Equal(t, 7, *params.Month)
+}
diff --git a/backend/plugins/kiro/models/user_model_messages.go
b/backend/plugins/kiro/models/user_model_messages.go
new file mode 100644
index 000000000..89268b44c
--- /dev/null
+++ b/backend/plugins/kiro/models/user_model_messages.go
@@ -0,0 +1,57 @@
+/*
+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"
+)
+
+// KiroUserModelMessage holds the per-model message counts from the report CSV.
+//
+// These arrive as dynamic columns ("claude_opus_4.6_messages") whose names and
+// count vary with the models a team uses, so they cannot live in a fixed
struct.
+// A narrow table also means a new model appears in the data without a
migration.
+//
+// This table - not KiroChatLog.ModelId - is the authoritative source for model
+// attribution: the interaction log carries ModelId on only 45% of records, so
+// any share-of-usage computed from it is systematically skewed.
+type KiroUserModelMessage struct {
+ common.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ UserId string `gorm:"primaryKey;type:varchar(64)"`
+ Date time.Time `gorm:"primaryKey;type:date"`
+ ClientType string `gorm:"primaryKey;type:varchar(20)"`
+ // ModelName keeps the CSV's underscore spelling ("claude_opus_4.6").
The
+ // interaction log spells the same model with hyphens; values are
stored as
+ // each source emits them and reconciled at query time, so the stored
value
+ // always traces back to its source.
+ //
+ // Note that some values are routing modes rather than models ("auto",
+ // "simple_task"). Upstream presents them through the same column
pattern,
+ // so no distinction is invented here.
+ ModelName string `gorm:"primaryKey;type:varchar(100)"`
+
+ MessageCount int `json:"messageCount"`
+}
+
+func (KiroUserModelMessage) TableName() string {
+ return "_tool_kiro_user_model_messages"
+}
diff --git a/backend/plugins/kiro/models/user_report.go
b/backend/plugins/kiro/models/user_report.go
new file mode 100644
index 000000000..8f5675508
--- /dev/null
+++ b/backend/plugins/kiro/models/user_report.go
@@ -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.
+*/
+
+package models
+
+import (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// KiroUserReport is one row of the daily per-user activity report.
+//
+// Kiro writes one CSV per client type per day, so the same person appears once
+// per client type they used - ClientType is therefore part of the key, not a
+// descriptive column.
+//
+// Several fields are nullable because the report schema has grown over time:
+// across the sampled history there are 19 distinct header layouts, and
+// User_Email and New_User were both introduced partway through. Rows collected
+// from earlier months legitimately lack them.
+type KiroUserReport struct {
+ common.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ // UserId is the bare UUID with any identity-store prefix stripped. Logs
+ // always carry the prefix and some report files do too, so both paths
+ // normalize - otherwise one person yields two distinct ids and every
+ // per-person aggregate is quietly wrong.
+ UserId string `gorm:"primaryKey;type:varchar(64)"`
+ Date time.Time `gorm:"primaryKey;type:date"`
+ // ClientType is KIRO_IDE, KIRO_CLI, KIRO_WEB or PLUGIN. KIRO_WEB
appears in
+ // real exports but not in the published docs, so this is not validated
+ // against a fixed set.
+ ClientType string `gorm:"primaryKey;type:varchar(20)"`
+
+ // IdentityStoreId is set only when the source value carried a prefix.
+ IdentityStoreId string `gorm:"type:varchar(32)" json:"identityStoreId"`
+ // UserEmail is the join key to git identity. Nullable: absent in early
+ // report versions.
+ UserEmail *string `gorm:"type:varchar(255);index" json:"userEmail"`
+ // DisplayName comes from IAM Identity Center and is for display only.
+ DisplayName *string `gorm:"type:varchar(255)" json:"displayName"`
+ // ProfileId is a full ARN, not a short id.
+ ProfileId string `gorm:"type:varchar(512)" json:"profileId"`
+ // SubscriptionTier is normalized to UPPER_SNAKE (POWER, PRO_PLUS).
+ SubscriptionTier string `gorm:"type:varchar(50)"
json:"subscriptionTier"`
+ // IsNewUser marks a subscription activated on the report date, which
gives
+ // an adoption timestamp directly instead of inferring one from first
usage.
+ // Nullable: absent in early report versions.
+ IsNewUser *bool `json:"isNewUser"`
+
+ ChatConversations int `json:"chatConversations"`
+ TotalMessages int `json:"totalMessages"`
+ CreditsUsed float64 `json:"creditsUsed"`
+ OverageCap float64 `json:"overageCap"`
+ // OverageCreditsUsed is genuinely non-zero in real data.
+ OverageCreditsUsed float64 `json:"overageCreditsUsed"`
+ OverageEnabled bool `json:"overageEnabled"`
+}
+
+func (KiroUserReport) TableName() string {
+ return "_tool_kiro_user_report"
+}
diff --git a/backend/plugins/kiro/tasks/normalize.go
b/backend/plugins/kiro/tasks/normalize.go
new file mode 100644
index 000000000..89350b993
--- /dev/null
+++ b/backend/plugins/kiro/tasks/normalize.go
@@ -0,0 +1,171 @@
+/*
+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 (
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+)
+
+// Kiro exports the same logical value in several shapes across its two data
+// sources. Every normalization helper here exists because a real sample proved
+// the variation exists; see the design spec section 3 for the evidence.
+
+// modelColumnRe matches the per-model message count columns in the user report
+// CSV, e.g. "claude_opus_4.6_messages". The column set is dynamic - it grows
+// and shrinks with the models a team actually uses.
+var modelColumnRe = regexp.MustCompile(`(?i)^(.+)_messages$`)
+
+// totalMessagesColumn is the aggregate column that must never be mistaken for
a
+// model. It matches modelColumnRe, and its value happens to equal the sum of
+// all model columns, so treating it as a model yields a plausible-looking but
+// wrong distribution.
+const totalMessagesColumn = "total_messages"
+
+// SplitUserId separates an identity store prefixed user id into its parts.
+//
+// Interaction logs always carry the prefix ("d-1234567890.11111111-..."),
while
+// report CSVs usually do not - but three sampled report files did. Both paths
+// must normalize, otherwise the same person lands in the table twice and every
+// per-person aggregate is silently wrong.
+//
+// Returns the bare user id, plus the identity store id when a prefix was
+// present (empty string otherwise).
+func SplitUserId(raw string) (userId string, identityStoreId string) {
+ trimmed := strings.TrimSpace(raw)
+ if !strings.HasPrefix(trimmed, "d-") {
+ return trimmed, ""
+ }
+ // Only the first separator matters: the identity store id itself never
+ // contains a dot, while the trailing UUID may not be validated here.
+ idx := strings.Index(trimmed, ".")
+ if idx < 0 {
+ // "d-something" with no separator is not a prefixed id.
+ return trimmed, ""
+ }
+ prefix := trimmed[:idx]
+ rest := trimmed[idx+1:]
+ if rest == "" {
+ // A trailing dot with nothing after it carries no user id;
keep the
+ // original value rather than inventing an empty one.
+ return trimmed, ""
+ }
+ return rest, prefix
+}
+
+// ParseKiroBool parses a boolean whose casing drifts between report versions:
+// March samples emit "TRUE", July samples emit "true".
+func ParseKiroBool(s string) (bool, errors.Error) {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "true":
+ return true, nil
+ case "false":
+ return false, nil
+ default:
+ return false, errors.Default.New("unrecognized boolean value: "
+ s)
+ }
+}
+
+// NormalizeTier canonicalizes a subscription tier to UPPER_SNAKE.
+//
+// Observed values are already UPPER_SNAKE ("POWER", "PRO_PLUS") while the
+// official docs write them in CamelCase ("Power", "ProPlus"). Unknown tiers
are
+// upper-cased and returned as-is rather than rejected - a new tier must not
+// break collection.
+func NormalizeTier(s string) string {
+ trimmed := strings.TrimSpace(s)
+ if trimmed == "" {
+ return ""
+ }
+ // Insert a separator at lower-to-upper transitions so "ProPlus" becomes
+ // "PRO_PLUS", while an already-snake "PRO_PLUS" is left alone.
+ var b strings.Builder
+ runes := []rune(trimmed)
+ for i, r := range runes {
+ if i > 0 && r >= 'A' && r <= 'Z' {
+ prev := runes[i-1]
+ if prev >= 'a' && prev <= 'z' {
+ b.WriteRune('_')
+ }
+ }
+ b.WriteRune(r)
+ }
+ return strings.ToUpper(b.String())
+}
+
+// ParseModelColumn reports whether a CSV header is a per-model message count
+// column, returning the model name when it is.
+//
+// "Total_Messages" is excluded explicitly: it matches the same pattern but is
+// the aggregate, not a model.
+func ParseModelColumn(header string) (string, bool) {
+ trimmed := strings.TrimSpace(header)
+ if strings.EqualFold(trimmed, totalMessagesColumn) {
+ return "", false
+ }
+ m := modelColumnRe.FindStringSubmatch(trimmed)
+ if m == nil {
+ return "", false
+ }
+ name := m[1]
+ if name == "" {
+ return "", false
+ }
+ return name, true
+}
+
+// CanonicalModelName folds the two spellings of a model identifier onto one
+// form so they can be compared.
+//
+// The report CSV uses underscores ("claude_opus_4.6") while interaction logs
+// use hyphens ("claude-opus-4.6"). Values are stored as the source emits them;
+// this helper is for comparison only.
+func CanonicalModelName(s string) string {
+ return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(s), "-",
"_"))
+}
+
+// ParseKiroTime parses an interaction log timestamp.
+//
+// Log timestamps carry nanosecond precision ("2026-07-27T23:03:29.027400929Z")
+// but MySQL DATETIME(6) only stores microseconds, so the value is truncated
+// here rather than at the storage layer - that keeps the truncation explicit
+// and identical on every database backend.
+func ParseKiroTime(s string) (time.Time, errors.Error) {
+ trimmed := strings.TrimSpace(s)
+ t, err := time.Parse(time.RFC3339Nano, trimmed)
+ if err != nil {
+ return time.Time{}, errors.Default.Wrap(err, "failed to parse
kiro timestamp: "+trimmed)
+ }
+ return t.Truncate(time.Microsecond), nil
+}
+
+// ParseReportDate parses a user report date, which is strictly ISO 8601.
+//
+// Deliberately strict: a lenient parser would accept the legacy
+// by_user_analytic format (MM-DD-YYYY) and silently produce a wrong date.
+func ParseReportDate(s string) (time.Time, errors.Error) {
+ trimmed := strings.TrimSpace(s)
+ t, err := time.Parse(time.DateOnly, trimmed)
+ if err != nil {
+ return time.Time{}, errors.Default.Wrap(err, "failed to parse
kiro report date: "+trimmed)
+ }
+ return t, nil
+}
diff --git a/backend/plugins/kiro/tasks/normalize_test.go
b/backend/plugins/kiro/tasks/normalize_test.go
new file mode 100644
index 000000000..d00cce892
--- /dev/null
+++ b/backend/plugins/kiro/tasks/normalize_test.go
@@ -0,0 +1,273 @@
+/*
+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 TestSplitUserId(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ wantUserId string
+ wantIdentityStr string
+ }{
+ {
+ // Every interaction log record carries this shape.
+ name: "prefixed as seen in interaction logs",
+ raw:
"d-1234567890.11111111-1111-4111-8111-111111111111",
+ wantUserId: "11111111-1111-4111-8111-111111111111",
+ wantIdentityStr: "d-1234567890",
+ },
+ {
+ // The common report CSV shape.
+ name: "bare uuid passes through",
+ raw: "11111111-1111-4111-8111-111111111111",
+ wantUserId: "11111111-1111-4111-8111-111111111111",
+ wantIdentityStr: "",
+ },
+ {
+ name: "empty",
+ raw: "",
+ wantUserId: "",
+ wantIdentityStr: "",
+ },
+ {
+ // A uuid containing dots but no d- prefix must not be
split.
+ name: "no d prefix but has dot",
+ raw: "abc.def",
+ wantUserId: "abc.def",
+ wantIdentityStr: "",
+ },
+ {
+ name: "d prefix without separator is not a
prefixed id",
+ raw: "d-1234567890",
+ wantUserId: "d-1234567890",
+ wantIdentityStr: "",
+ },
+ {
+ name: "trailing dot carries no user id",
+ raw: "d-1234567890.",
+ wantUserId: "d-1234567890.",
+ wantIdentityStr: "",
+ },
+ {
+ // Only the first separator splits; the remainder is
the user id
+ // verbatim even if it contains further dots.
+ name: "multiple dots split on the first",
+ raw: "d-abc.part1.part2",
+ wantUserId: "part1.part2",
+ wantIdentityStr: "d-abc",
+ },
+ {
+ name: "surrounding whitespace trimmed",
+ raw: " d-1234567890.11111111 ",
+ wantUserId: "11111111",
+ wantIdentityStr: "d-1234567890",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotUserId, gotIdentityStore := SplitUserId(tt.raw)
+ assert.Equal(t, tt.wantUserId, gotUserId)
+ assert.Equal(t, tt.wantIdentityStr, gotIdentityStore)
+ })
+ }
+}
+
+func TestParseKiroBool(t *testing.T) {
+ trueCases := []string{"true", "TRUE", "True", " true "}
+ for _, c := range trueCases {
+ t.Run("true/"+c, func(t *testing.T) {
+ got, err := ParseKiroBool(c)
+ assert.Nil(t, err)
+ assert.True(t, got)
+ })
+ }
+
+ falseCases := []string{"false", "FALSE", "False"}
+ for _, c := range falseCases {
+ t.Run("false/"+c, func(t *testing.T) {
+ got, err := ParseKiroBool(c)
+ assert.Nil(t, err)
+ assert.False(t, got)
+ })
+ }
+
+ // An unrecognized value must surface as an error rather than default to
+ // false - a silent false would be indistinguishable from real data.
+ errCases := []string{"", "yes", "1", "0", "null"}
+ for _, c := range errCases {
+ t.Run("error/"+c, func(t *testing.T) {
+ _, err := ParseKiroBool(c)
+ assert.NotNil(t, err)
+ })
+ }
+}
+
+func TestNormalizeTier(t *testing.T) {
+ tests := []struct {
+ in string
+ want string
+ }{
+ // Observed values are already canonical.
+ {"POWER", "POWER"},
+ {"PRO_PLUS", "PRO_PLUS"},
+ // The docs spell them in CamelCase.
+ {"Power", "POWER"},
+ {"ProPlus", "PRO_PLUS"},
+ {"ProMax", "PRO_MAX"},
+ {"Pro", "PRO"},
+ // Unknown tiers must pass through rather than break collection.
+ {"SomeFutureTier", "SOME_FUTURE_TIER"},
+ {"power", "POWER"},
+ {"", ""},
+ {" POWER ", "POWER"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.in, func(t *testing.T) {
+ assert.Equal(t, tt.want, NormalizeTier(tt.in))
+ })
+ }
+}
+
+func TestParseModelColumn(t *testing.T) {
+ // All eight model/mode columns observed across the full report history.
+ modelColumns := map[string]string{
+ "claude_opus_4.6_messages": "claude_opus_4.6",
+ "claude_opus_4.7_messages": "claude_opus_4.7",
+ "claude_opus_4.8_messages": "claude_opus_4.8",
+ "claude_opus_5_messages": "claude_opus_5",
+ "claude_sonnet_4.6_messages": "claude_sonnet_4.6",
+ "gpt_5.6_sol_messages": "gpt_5.6_sol",
+ "auto_messages": "auto",
+ "simple_task_messages": "simple_task",
+ }
+ for header, want := range modelColumns {
+ t.Run("model/"+header, func(t *testing.T) {
+ got, ok := ParseModelColumn(header)
+ assert.True(t, ok)
+ assert.Equal(t, want, got)
+ })
+ }
+
+ // Total_Messages matches the pattern but is the aggregate. If it were
+ // treated as a model named "Total", its value would equal the sum of
all
+ // real models - a wrong result that looks entirely plausible
downstream.
+ // The exclusion is case-insensitive because the pattern is.
+ notModels := []string{
+ "Total_Messages",
+ "total_messages",
+ "TOTAL_MESSAGES",
+ " Total_Messages ",
+ "Chat_Conversations",
+ "Credits_Used",
+ "User_Email",
+ "_messages",
+ "messages",
+ "",
+ }
+ for _, header := range notModels {
+ t.Run("not_model/"+header, func(t *testing.T) {
+ got, ok := ParseModelColumn(header)
+ assert.False(t, ok, "header %q must not be treated as a
model", header)
+ assert.Equal(t, "", got)
+ })
+ }
+}
+
+func TestCanonicalModelName(t *testing.T) {
+ // The two sources spell the same model differently; canonical form lets
+ // them be compared without rewriting either source's stored value.
+ assert.Equal(t,
+ CanonicalModelName("claude-opus-4.6"),
+ CanonicalModelName("claude_opus_4.6"),
+ )
+ assert.Equal(t,
+ CanonicalModelName("simple-task"),
+ CanonicalModelName("simple_task"),
+ )
+ // Distinct models must stay distinct.
+ assert.NotEqual(t,
+ CanonicalModelName("claude-opus-4.6"),
+ CanonicalModelName("claude-opus-4.8"),
+ )
+ assert.Equal(t, "claude_opus_4.6",
CanonicalModelName("Claude-Opus-4.6"))
+}
+
+func TestParseKiroTime(t *testing.T) {
+ // The real shape emitted by interaction logs.
+ t.Run("nanosecond precision truncates to microsecond", func(t
*testing.T) {
+ got, err := ParseKiroTime("2026-07-27T23:03:29.027400929Z")
+ assert.Nil(t, err)
+ want := time.Date(2026, 7, 27, 23, 3, 29, 27400000, time.UTC)
+ assert.True(t, got.Equal(want), "got %v want %v", got, want)
+ // Nanosecond remainder beyond microsecond precision must be
gone, so
+ // the value round-trips through DATETIME(6) unchanged.
+ assert.Equal(t, 0, got.Nanosecond()%1000)
+ })
+
+ t.Run("microsecond precision preserved", func(t *testing.T) {
+ got, err := ParseKiroTime("2026-07-27T23:03:29.027400Z")
+ assert.Nil(t, err)
+ assert.Equal(t, 27400000, got.Nanosecond())
+ })
+
+ t.Run("no fractional seconds", func(t *testing.T) {
+ got, err := ParseKiroTime("2026-07-27T23:03:29Z")
+ assert.Nil(t, err)
+ assert.Equal(t, 0, got.Nanosecond())
+ assert.Equal(t, 29, got.Second())
+ })
+
+ for _, bad := range []string{"", "not-a-time", "2026-07-27",
"07-27-2026"} {
+ t.Run("error/"+bad, func(t *testing.T) {
+ _, err := ParseKiroTime(bad)
+ assert.NotNil(t, err)
+ })
+ }
+}
+
+func TestParseReportDate(t *testing.T) {
+ t.Run("iso date", func(t *testing.T) {
+ got, err := ParseReportDate("2026-07-27")
+ assert.Nil(t, err)
+ assert.Equal(t, 2026, got.Year())
+ assert.Equal(t, time.July, got.Month())
+ assert.Equal(t, 27, got.Day())
+ })
+
+ // The legacy by_user_analytic report used MM-DD-YYYY. That report is
out of
+ // scope, but a lenient parser would accept its format here and produce
a
+ // wrong date without any error - so rejection is asserted explicitly.
+ t.Run("legacy MM-DD-YYYY rejected", func(t *testing.T) {
+ _, err := ParseReportDate("07-27-2026")
+ assert.NotNil(t, err)
+ })
+
+ for _, bad := range []string{"", "2026/07/27", "2026-13-45",
"not-a-date"} {
+ t.Run("error/"+bad, func(t *testing.T) {
+ _, err := ParseReportDate(bad)
+ assert.NotNil(t, err)
+ })
+ }
+}
diff --git
a/backend/plugins/kiro/tasks/testdata/user_report/01_earliest_no_email_no_newuser.csv
b/backend/plugins/kiro/tasks/testdata/user_report/01_earliest_no_email_no_newuser.csv
new file mode 100644
index 000000000..44b1f3b2e
--- /dev/null
+++
b/backend/plugins/kiro/tasks/testdata/user_report/01_earliest_no_email_no_newuser.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages
+2026-03-05,"11111111-1111-4111-8111-111111111111",KIRO_CLI,4,6.421250216662884,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,28
diff --git
a/backend/plugins/kiro/tasks/testdata/user_report/02_standard_cli.csv
b/backend/plugins/kiro/tasks/testdata/user_report/02_standard_cli.csv
new file mode 100644
index 000000000..f14255cf9
--- /dev/null
+++ b/backend/plugins/kiro/tasks/testdata/user_report/02_standard_cli.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,New_User,User_Email,claude_opus_5_messages
+2026-07-27,"11111111-1111-4111-8111-111111111111",KIRO_CLI,39,308.97581546497514,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,618,false,"[email protected]",618
diff --git
a/backend/plugins/kiro/tasks/testdata/user_report/03_standard_ide.csv
b/backend/plugins/kiro/tasks/testdata/user_report/03_standard_ide.csv
new file mode 100644
index 000000000..2f4920cb2
--- /dev/null
+++ b/backend/plugins/kiro/tasks/testdata/user_report/03_standard_ide.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,New_User,User_Email,claude_opus_5_messages
+2026-07-27,"11111111-1111-4111-8111-111111111111",KIRO_IDE,2,19.59644039641791,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,30,false,"[email protected]",30
diff --git
a/backend/plugins/kiro/tasks/testdata/user_report/04_newuser_after_models.csv
b/backend/plugins/kiro/tasks/testdata/user_report/04_newuser_after_models.csv
new file mode 100644
index 000000000..8a2be22b8
--- /dev/null
+++
b/backend/plugins/kiro/tasks/testdata/user_report/04_newuser_after_models.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,claude_opus_4.6_messages,claude_opus_4.7_messages,New_User
+2026-05-20,"11111111-1111-4111-8111-111111111111",KIRO_CLI,4,1155.1243810902488,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,612,550,62,false
diff --git
a/backend/plugins/kiro/tasks/testdata/user_report/05_prefixed_userid_upper_bool.csv
b/backend/plugins/kiro/tasks/testdata/user_report/05_prefixed_userid_upper_bool.csv
new file mode 100644
index 000000000..0e3744b51
--- /dev/null
+++
b/backend/plugins/kiro/tasks/testdata/user_report/05_prefixed_userid_upper_bool.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,claude_opus_4.6_messages,simple_task_messages
+2026-03-10,d-1234567890.11111111-1111-4111-8111-111111111111,KIRO_IDE,3,121.91792578529567,10000.0,0.0,TRUE,arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234,POWER,240,212,28
diff --git a/backend/plugins/kiro/tasks/testdata/user_report/06_two_users.csv
b/backend/plugins/kiro/tasks/testdata/user_report/06_two_users.csv
new file mode 100644
index 000000000..de2fc7c3f
--- /dev/null
+++ b/backend/plugins/kiro/tasks/testdata/user_report/06_two_users.csv
@@ -0,0 +1,3 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages
+2026-03-24,"22222222-2222-4222-8222-222222222222",KIRO_IDE,1,2.960539370775223,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,5
+2026-03-24,"11111111-1111-4111-8111-111111111111",KIRO_IDE,4,13.467958366498351,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,17
diff --git
a/backend/plugins/kiro/tasks/testdata/user_report/07_kiro_web_nonzero_overage.csv
b/backend/plugins/kiro/tasks/testdata/user_report/07_kiro_web_nonzero_overage.csv
new file mode 100644
index 000000000..430963d80
--- /dev/null
+++
b/backend/plugins/kiro/tasks/testdata/user_report/07_kiro_web_nonzero_overage.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,New_User,User_Email,claude_opus_4.8_messages
+2026-06-21,"11111111-1111-4111-8111-111111111111",KIRO_WEB,1,1.281141613432836,10000.0,1.281141613432836,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,1,false,"[email protected]",1
diff --git
a/backend/plugins/kiro/tasks/testdata/user_report/08_plugin_multi_model.csv
b/backend/plugins/kiro/tasks/testdata/user_report/08_plugin_multi_model.csv
new file mode 100644
index 000000000..d2b60a05b
--- /dev/null
+++ b/backend/plugins/kiro/tasks/testdata/user_report/08_plugin_multi_model.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,New_User,User_Email,auto_messages,claude_sonnet_4.6_messages
+2026-06-07,"11111111-1111-4111-8111-111111111111",PLUGIN,1,1.6401393711566925,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,16,false,"[email protected]",9,7
diff --git a/backend/plugins/kiro/tasks/user_report_parser.go
b/backend/plugins/kiro/tasks/user_report_parser.go
new file mode 100644
index 000000000..7909b31cf
--- /dev/null
+++ b/backend/plugins/kiro/tasks/user_report_parser.go
@@ -0,0 +1,294 @@
+/*
+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 (
+ "bytes"
+ "encoding/csv"
+ "io"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/plugins/kiro/models"
+)
+
+// Known column names in the user activity report. Only the first eleven are
+// stable; everything after Total_Messages varies by report version.
+const (
+ colDate = "Date"
+ colUserId = "UserId"
+ colClientType = "Client_Type"
+ colChatConversations = "Chat_Conversations"
+ colCreditsUsed = "Credits_Used"
+ colOverageCap = "Overage_Cap"
+ colOverageCreditsUsed = "Overage_Credits_Used"
+ colOverageEnabled = "Overage_Enabled"
+ colProfileId = "ProfileId"
+ colSubscriptionTier = "Subscription_Tier"
+ colTotalMessages = "Total_Messages"
+ colNewUser = "New_User"
+ colUserEmail = "User_Email"
+)
+
+// ParseUserReport parses a user activity report CSV into report rows and their
+// per-model message counts.
+//
+// The parser is deliberately tolerant of schema drift. Across the sampled
+// history the report has had 19 distinct header layouts: columns were added
+// over time, their order moved, and the per-model columns come and go with the
+// models a team uses. So:
+//
+// - every field is located by column name, never by position
+// - unknown columns are ignored rather than treated as an error
+// - columns that are absent yield NULL, not a zero value
+//
+// It is a pure function: no S3, no database, no task context. That keeps it
+// testable against real sample bytes.
+func ParseUserReport(data []byte, connectionId uint64, scopeId string)
([]*models.KiroUserReport, []*models.KiroUserModelMessage, errors.Error) {
+ reader := csv.NewReader(bytes.NewReader(data))
+ // Row width varies across report versions, so do not enforce a fixed
count.
+ reader.FieldsPerRecord = -1
+
+ headers, err := reader.Read()
+ if err != nil {
+ if err == io.EOF {
+ // An empty file is not a failure; it simply has no
rows.
+ return nil, nil, nil
+ }
+ return nil, nil, errors.Default.Wrap(err, "failed to read user
report CSV header")
+ }
+ for i := range headers {
+ headers[i] = strings.TrimSpace(headers[i])
+ }
+
+ var reports []*models.KiroUserReport
+ var modelMessages []*models.KiroUserModelMessage
+
+ for {
+ record, readErr := reader.Read()
+ if readErr == io.EOF {
+ break
+ }
+ if readErr != nil {
+ return nil, nil, errors.Default.Wrap(readErr, "failed
to read user report CSV row")
+ }
+ if isBlankRecord(record) {
+ continue
+ }
+
+ fields := zipHeaders(headers, record)
+
+ report, rowModels, rowErr := buildReportRow(fields,
connectionId, scopeId)
+ if rowErr != nil {
+ return nil, nil, rowErr
+ }
+ reports = append(reports, report)
+ modelMessages = append(modelMessages, rowModels...)
+ }
+
+ return reports, modelMessages, nil
+}
+
+// buildReportRow converts one name-keyed CSV row into its models.
+func buildReportRow(fields map[string]string, connectionId uint64, scopeId
string) (*models.KiroUserReport, []*models.KiroUserModelMessage, errors.Error) {
+ date, err := ParseReportDate(fields[colDate])
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Report CSVs usually carry a bare UUID, but some files carry the
+ // identity-store prefix. Normalizing both paths keeps one person from
+ // splitting into two ids.
+ userId, identityStoreId := SplitUserId(fields[colUserId])
+ clientType := strings.TrimSpace(fields[colClientType])
+
+ report := &models.KiroUserReport{
+ ConnectionId: connectionId,
+ ScopeId: scopeId,
+ UserId: userId,
+ Date: date,
+ ClientType: clientType,
+ IdentityStoreId: identityStoreId,
+ ProfileId: strings.TrimSpace(fields[colProfileId]),
+ // Tiers arrive as UPPER_SNAKE in real data but CamelCase in
the docs;
+ // normalizing means an unrecognized tier still stores cleanly.
+ SubscriptionTier: NormalizeTier(fields[colSubscriptionTier]),
+ }
+
+ // These two columns were introduced partway through the report's
history.
+ // When absent they must stay NULL: a zero value would be
indistinguishable
+ // from real data, and an empty email would look like a failed identity
join
+ // rather than an unavailable one.
+ if raw, ok := fields[colUserEmail]; ok {
+ if email := strings.TrimSpace(raw); email != "" {
+ report.UserEmail = &email
+ }
+ }
+ if raw, ok := fields[colNewUser]; ok {
+ if trimmed := strings.TrimSpace(raw); trimmed != "" {
+ isNew, boolErr := ParseKiroBool(trimmed)
+ if boolErr != nil {
+ return nil, nil, boolErr
+ }
+ report.IsNewUser = &isNew
+ }
+ }
+
+ var intErr errors.Error
+ if report.ChatConversations, intErr = optionalInt(fields,
colChatConversations); intErr != nil {
+ return nil, nil, intErr
+ }
+ if report.TotalMessages, intErr = optionalInt(fields,
colTotalMessages); intErr != nil {
+ return nil, nil, intErr
+ }
+
+ var floatErr errors.Error
+ if report.CreditsUsed, floatErr = optionalFloat(fields,
colCreditsUsed); floatErr != nil {
+ return nil, nil, floatErr
+ }
+ if report.OverageCap, floatErr = optionalFloat(fields, colOverageCap);
floatErr != nil {
+ return nil, nil, floatErr
+ }
+ // Genuinely non-zero in real data, so it is parsed rather than assumed.
+ if report.OverageCreditsUsed, floatErr = optionalFloat(fields,
colOverageCreditsUsed); floatErr != nil {
+ return nil, nil, floatErr
+ }
+
+ if raw, ok := fields[colOverageEnabled]; ok {
+ if trimmed := strings.TrimSpace(raw); trimmed != "" {
+ // Casing drifts between report versions (TRUE vs true).
+ enabled, boolErr := ParseKiroBool(trimmed)
+ if boolErr != nil {
+ return nil, nil, boolErr
+ }
+ report.OverageEnabled = enabled
+ }
+ }
+
+ modelMessages, modelErr := buildModelMessages(fields, connectionId,
scopeId, userId, date, clientType)
+ if modelErr != nil {
+ return nil, nil, modelErr
+ }
+
+ return report, modelMessages, nil
+}
+
+// buildModelMessages extracts the dynamic per-model message count columns.
+//
+// Total_Messages matches the same naming pattern but is excluded by
+// ParseModelColumn - it is the aggregate, and admitting it would create a
+// phantom model whose count equals the sum of the real ones.
+func buildModelMessages(
+ fields map[string]string,
+ connectionId uint64,
+ scopeId string,
+ userId string,
+ date time.Time,
+ clientType string,
+) ([]*models.KiroUserModelMessage, errors.Error) {
+ var result []*models.KiroUserModelMessage
+ for header, value := range fields {
+ modelName, ok := ParseModelColumn(header)
+ if !ok {
+ continue
+ }
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ continue
+ }
+ count, err := strconv.Atoi(trimmed)
+ if err != nil {
+ return nil, errors.Default.Wrap(err, "failed to parse
message count for model "+modelName)
+ }
+ result = append(result, &models.KiroUserModelMessage{
+ ConnectionId: connectionId,
+ ScopeId: scopeId,
+ UserId: userId,
+ Date: date,
+ ClientType: clientType,
+ // Stored with the CSV's underscore spelling;
reconciliation with the
+ // log's hyphenated form happens at query time.
+ ModelName: modelName,
+ MessageCount: count,
+ })
+ }
+ return result, nil
+}
+
+// zipHeaders pairs header names with row values.
+//
+// Short rows are tolerated: a missing trailing column is simply absent from
the
+// map, which downstream reads as NULL rather than as a zero value.
+func zipHeaders(headers, record []string) map[string]string {
+ fields := make(map[string]string, len(headers))
+ for i, header := range headers {
+ if i >= len(record) {
+ break
+ }
+ if header == "" {
+ continue
+ }
+ fields[header] = record[i]
+ }
+ return fields
+}
+
+func isBlankRecord(record []string) bool {
+ for _, v := range record {
+ if strings.TrimSpace(v) != "" {
+ return false
+ }
+ }
+ return true
+}
+
+// optionalInt returns 0 when the column is absent or empty. Counters are
+// genuinely zero in that case, unlike the nullable identity columns.
+func optionalInt(fields map[string]string, column string) (int, errors.Error) {
+ raw, ok := fields[column]
+ if !ok {
+ return 0, nil
+ }
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return 0, nil
+ }
+ v, err := strconv.Atoi(trimmed)
+ if err != nil {
+ return 0, errors.Default.Wrap(err, "failed to parse integer
column "+column)
+ }
+ return v, nil
+}
+
+func optionalFloat(fields map[string]string, column string) (float64,
errors.Error) {
+ raw, ok := fields[column]
+ if !ok {
+ return 0, nil
+ }
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return 0, nil
+ }
+ v, err := strconv.ParseFloat(trimmed, 64)
+ if err != nil {
+ return 0, errors.Default.Wrap(err, "failed to parse float
column "+column)
+ }
+ return v, nil
+}
diff --git a/backend/plugins/kiro/tasks/user_report_parser_test.go
b/backend/plugins/kiro/tasks/user_report_parser_test.go
new file mode 100644
index 000000000..112a27023
--- /dev/null
+++ b/backend/plugins/kiro/tasks/user_report_parser_test.go
@@ -0,0 +1,312 @@
+/*
+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 (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/apache/incubator-devlake/plugins/kiro/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ testConnectionId = uint64(1)
+ testScopeId = "123456789012_2026_07"
+)
+
+// Fixtures are real report files with only the user id, email and profile
+// suffix replaced. Every numeric value is untouched, so the assertions below
+// double as a record of what Kiro actually emits.
+func loadReportFixture(t *testing.T, name string) []byte {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join("testdata", "user_report", name))
+ require.NoError(t, err)
+ return data
+}
+
+func parseReportFixture(t *testing.T, name string) ([]*models.KiroUserReport,
[]*models.KiroUserModelMessage) {
+ t.Helper()
+ reports, modelMessages, err := ParseUserReport(loadReportFixture(t,
name), testConnectionId, testScopeId)
+ require.Nil(t, err)
+ return reports, modelMessages
+}
+
+// The earliest report layout has neither User_Email nor New_User nor any model
+// column. Both identity fields must be NULL rather than empty, because a blank
+// email would read as a failed identity join instead of an unavailable one.
+func TestParseUserReport_EarliestVariant(t *testing.T) {
+ reports, modelMessages := parseReportFixture(t,
"01_earliest_no_email_no_newuser.csv")
+
+ require.Len(t, reports, 1)
+ r := reports[0]
+ assert.Nil(t, r.UserEmail, "User_Email column is absent, so the field
must be NULL")
+ assert.Nil(t, r.IsNewUser, "New_User column is absent, so the field
must be NULL")
+ assert.Equal(t, "KIRO_CLI", r.ClientType)
+ assert.Equal(t, "POWER", r.SubscriptionTier)
+ assert.Equal(t, 4, r.ChatConversations)
+ assert.Equal(t, 28, r.TotalMessages)
+ assert.InDelta(t, 6.421250216662884, r.CreditsUsed, 1e-12)
+ assert.Empty(t, modelMessages, "no model columns in this variant")
+}
+
+func TestParseUserReport_StandardCliAndIde(t *testing.T) {
+ // Same person, same day, two client types - which is why ClientType is
part
+ // of the primary key.
+ cli, cliModels := parseReportFixture(t, "02_standard_cli.csv")
+ require.Len(t, cli, 1)
+ assert.Equal(t, "KIRO_CLI", cli[0].ClientType)
+ assert.Equal(t, 618, cli[0].TotalMessages)
+ assert.Equal(t, 39, cli[0].ChatConversations)
+ assert.InDelta(t, 308.97581546497514, cli[0].CreditsUsed, 1e-12)
+ require.NotNil(t, cli[0].UserEmail)
+ assert.Equal(t, "[email protected]", *cli[0].UserEmail)
+ require.NotNil(t, cli[0].IsNewUser)
+ assert.False(t, *cli[0].IsNewUser)
+ require.Len(t, cliModels, 1)
+ assert.Equal(t, "claude_opus_5", cliModels[0].ModelName)
+ assert.Equal(t, 618, cliModels[0].MessageCount)
+
+ ide, ideModels := parseReportFixture(t, "03_standard_ide.csv")
+ require.Len(t, ide, 1)
+ assert.Equal(t, "KIRO_IDE", ide[0].ClientType)
+ assert.Equal(t, 30, ide[0].TotalMessages)
+ assert.InDelta(t, 19.59644039641791, ide[0].CreditsUsed, 1e-12)
+ require.Len(t, ideModels, 1)
+ assert.Equal(t, 30, ideModels[0].MessageCount)
+
+ // The two rows share a user and date but differ in client type.
+ assert.Equal(t, cli[0].UserId, ide[0].UserId)
+ assert.Equal(t, cli[0].Date, ide[0].Date)
+ assert.NotEqual(t, cli[0].ClientType, ide[0].ClientType)
+}
+
+// In this layout New_User comes after the model columns rather than before
+// them, which is why nothing may be located by position.
+func TestParseUserReport_NewUserAfterModelColumns(t *testing.T) {
+ reports, modelMessages := parseReportFixture(t,
"04_newuser_after_models.csv")
+
+ require.Len(t, reports, 1)
+ require.NotNil(t, reports[0].IsNewUser)
+ assert.False(t, *reports[0].IsNewUser)
+ assert.Equal(t, 612, reports[0].TotalMessages)
+
+ require.Len(t, modelMessages, 2)
+ counts := map[string]int{}
+ for _, m := range modelMessages {
+ counts[m.ModelName] = m.MessageCount
+ }
+ assert.Equal(t, 550, counts["claude_opus_4.6"])
+ assert.Equal(t, 62, counts["claude_opus_4.7"])
+ // Total_Messages must never surface as a model.
+ assert.NotContains(t, counts, "Total")
+ assert.NotContains(t, counts, "Total_Messages")
+}
+
+// This file carries the identity-store prefix in the CSV's UserId column and
+// spells its boolean in upper case. Without prefix stripping the same person
+// would exist under two ids and every per-person aggregate would be wrong.
+func TestParseUserReport_PrefixedUserIdAndUpperCaseBool(t *testing.T) {
+ reports, modelMessages := parseReportFixture(t,
"05_prefixed_userid_upper_bool.csv")
+
+ require.Len(t, reports, 1)
+ r := reports[0]
+ assert.Equal(t, "11111111-1111-4111-8111-111111111111", r.UserId,
+ "the d-... prefix must be stripped from the CSV path too")
+ assert.Equal(t, "d-1234567890", r.IdentityStoreId,
+ "the prefix is retained separately for auditability")
+ assert.True(t, r.OverageEnabled, "TRUE in upper case must parse")
+ assert.Equal(t, 240, r.TotalMessages)
+
+ require.Len(t, modelMessages, 2)
+ counts := map[string]int{}
+ for _, m := range modelMessages {
+ counts[m.ModelName] = m.MessageCount
+ // Model rows must carry the normalized id, or they will not
join to the
+ // report row.
+ assert.Equal(t, r.UserId, m.UserId)
+ }
+ assert.Equal(t, 212, counts["claude_opus_4.6"])
+ // "simple_task" is a routing mode rather than a model, but upstream
exposes
+ // it through the same column pattern so it is stored the same way.
+ assert.Equal(t, 28, counts["simple_task"])
+}
+
+func TestParseUserReport_MultipleUsers(t *testing.T) {
+ reports, _ := parseReportFixture(t, "06_two_users.csv")
+
+ require.Len(t, reports, 2, "a single file can hold more than one user")
+ ids := []string{reports[0].UserId, reports[1].UserId}
+ assert.NotEqual(t, ids[0], ids[1])
+ for _, r := range reports {
+ assert.Equal(t, "KIRO_IDE", r.ClientType)
+ assert.NotEmpty(t, r.UserId)
+ }
+}
+
+// KIRO_WEB is absent from the published documentation but present in real
+// exports, so client type is never validated against a fixed set. This file
+// also carries a non-zero overage figure.
+func TestParseUserReport_KiroWebAndNonZeroOverage(t *testing.T) {
+ reports, _ := parseReportFixture(t, "07_kiro_web_nonzero_overage.csv")
+
+ require.Len(t, reports, 1)
+ r := reports[0]
+ assert.Equal(t, "KIRO_WEB", r.ClientType)
+ assert.InDelta(t, 1.281141613432836, r.OverageCreditsUsed, 1e-12,
+ "overage credits are genuinely non-zero in real data")
+ assert.InDelta(t, 1.281141613432836, r.CreditsUsed, 1e-12)
+}
+
+func TestParseUserReport_PluginMultiModel(t *testing.T) {
+ reports, modelMessages := parseReportFixture(t,
"08_plugin_multi_model.csv")
+
+ require.Len(t, reports, 1)
+ assert.Equal(t, "PLUGIN", reports[0].ClientType)
+
+ require.Len(t, modelMessages, 2)
+ counts := map[string]int{}
+ for _, m := range modelMessages {
+ counts[m.ModelName] = m.MessageCount
+ }
+ assert.Equal(t, 9, counts["auto"])
+ assert.Equal(t, 7, counts["claude_sonnet_4.6"])
+}
+
+// Every fixture must satisfy the invariant that the per-model counts add up to
+// Total_Messages. This catches a parser that drops or double-counts a column.
+func TestParseUserReport_ModelCountsSumToTotal(t *testing.T) {
+ fixtures := []string{
+ "02_standard_cli.csv",
+ "03_standard_ide.csv",
+ "04_newuser_after_models.csv",
+ "05_prefixed_userid_upper_bool.csv",
+ "07_kiro_web_nonzero_overage.csv",
+ "08_plugin_multi_model.csv",
+ }
+ for _, name := range fixtures {
+ t.Run(name, func(t *testing.T) {
+ reports, modelMessages := parseReportFixture(t, name)
+ require.Len(t, reports, 1)
+
+ sum := 0
+ for _, m := range modelMessages {
+ sum += m.MessageCount
+ }
+ assert.Equal(t, reports[0].TotalMessages, sum,
+ "per-model counts must add up to
Total_Messages")
+ })
+ }
+}
+
+func TestParseUserReport_EdgeCases(t *testing.T) {
+ t.Run("empty input yields no rows and no error", func(t *testing.T) {
+ reports, modelMessages, err := ParseUserReport(nil,
testConnectionId, testScopeId)
+ assert.Nil(t, err)
+ assert.Empty(t, reports)
+ assert.Empty(t, modelMessages)
+ })
+
+ t.Run("header only yields no rows", func(t *testing.T) {
+ csvData := []byte("Date,UserId,Client_Type,Total_Messages\n")
+ reports, modelMessages, err := ParseUserReport(csvData,
testConnectionId, testScopeId)
+ assert.Nil(t, err)
+ assert.Empty(t, reports)
+ assert.Empty(t, modelMessages)
+ })
+
+ t.Run("blank trailing line is skipped", func(t *testing.T) {
+ // Real exports end with a trailing newline, which the CSV
reader
+ // surfaces as an empty record on some inputs.
+ csvData :=
[]byte("Date,UserId,Client_Type,Total_Messages\n2026-07-27,u1,KIRO_CLI,5\n\n")
+ reports, _, err := ParseUserReport(csvData, testConnectionId,
testScopeId)
+ assert.Nil(t, err)
+ assert.Len(t, reports, 1)
+ })
+
+ // A brand new column must not break collection - that is the whole
point of
+ // name-based mapping.
+ t.Run("unknown columns are ignored", func(t *testing.T) {
+ csvData :=
[]byte("Date,UserId,Client_Type,Total_Messages,Some_Future_Column\n" +
+ "2026-07-27,u1,KIRO_CLI,5,whatever\n")
+ reports, _, err := ParseUserReport(csvData, testConnectionId,
testScopeId)
+ assert.Nil(t, err)
+ require.Len(t, reports, 1)
+ assert.Equal(t, 5, reports[0].TotalMessages)
+ })
+
+ t.Run("short row leaves trailing columns absent", func(t *testing.T) {
+ csvData :=
[]byte("Date,UserId,Client_Type,Total_Messages,User_Email\n" +
+ "2026-07-27,u1,KIRO_CLI,5\n")
+ reports, _, err := ParseUserReport(csvData, testConnectionId,
testScopeId)
+ assert.Nil(t, err)
+ require.Len(t, reports, 1)
+ assert.Nil(t, reports[0].UserEmail)
+ })
+
+ t.Run("empty email stays NULL", func(t *testing.T) {
+ csvData :=
[]byte("Date,UserId,Client_Type,Total_Messages,User_Email\n" +
+ "2026-07-27,u1,KIRO_CLI,5,\n")
+ reports, _, err := ParseUserReport(csvData, testConnectionId,
testScopeId)
+ assert.Nil(t, err)
+ require.Len(t, reports, 1)
+ assert.Nil(t, reports[0].UserEmail)
+ })
+
+ t.Run("zero model count is still recorded", func(t *testing.T) {
+ csvData :=
[]byte("Date,UserId,Client_Type,Total_Messages,auto_messages\n" +
+ "2026-07-27,u1,KIRO_CLI,0,0\n")
+ _, modelMessages, err := ParseUserReport(csvData,
testConnectionId, testScopeId)
+ assert.Nil(t, err)
+ require.Len(t, modelMessages, 1)
+ assert.Equal(t, 0, modelMessages[0].MessageCount)
+ })
+
+ t.Run("bad date is an error", func(t *testing.T) {
+ csvData :=
[]byte("Date,UserId,Client_Type\n07-27-2026,u1,KIRO_CLI\n")
+ _, _, err := ParseUserReport(csvData, testConnectionId,
testScopeId)
+ assert.NotNil(t, err, "the legacy MM-DD-YYYY format must not
parse silently")
+ })
+
+ t.Run("bad number is an error", func(t *testing.T) {
+ csvData :=
[]byte("Date,UserId,Client_Type,Total_Messages\n2026-07-27,u1,KIRO_CLI,not-a-number\n")
+ _, _, err := ParseUserReport(csvData, testConnectionId,
testScopeId)
+ assert.NotNil(t, err)
+ })
+
+ t.Run("bad boolean is an error", func(t *testing.T) {
+ csvData :=
[]byte("Date,UserId,Client_Type,Overage_Enabled\n2026-07-27,u1,KIRO_CLI,maybe\n")
+ _, _, err := ParseUserReport(csvData, testConnectionId,
testScopeId)
+ assert.NotNil(t, err)
+ })
+
+ t.Run("connection and scope are propagated", func(t *testing.T) {
+ csvData :=
[]byte("Date,UserId,Client_Type,Total_Messages,auto_messages\n" +
+ "2026-07-27,u1,KIRO_CLI,5,5\n")
+ reports, modelMessages, err := ParseUserReport(csvData, 42,
"scope-x")
+ assert.Nil(t, err)
+ require.Len(t, reports, 1)
+ require.Len(t, modelMessages, 1)
+ assert.Equal(t, uint64(42), reports[0].ConnectionId)
+ assert.Equal(t, "scope-x", reports[0].ScopeId)
+ assert.Equal(t, uint64(42), modelMessages[0].ConnectionId)
+ assert.Equal(t, "scope-x", modelMessages[0].ScopeId)
+ })
+}