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 705280d08 feat(gh-copilot): add AI credit usage billing metrics and
fix report-body double-read (#9019)
705280d08 is described below
commit 705280d08ccc6a19f6b84ac2e17812a8e24a71d1
Author: Pankaj Chaudhari
<[email protected]>
AuthorDate: Thu Jul 30 21:45:58 2026 +0530
feat(gh-copilot): add AI credit usage billing metrics and fix report-body
double-read (#9019)
* feat(gh-copilot): add AI credit usage billing metrics
Adds collection and extraction of GitHub Copilot AI credit usage
billing metrics at org, user, and enterprise levels, including new
models, tasks (ai_credit_collector/extractor), a migration for the
new billing tables, and e2e snapshot fixtures.
Ports the work from apache/devlake#8980.
Co-authored-by: Andrei Savu <[email protected]>
Signed-off-by: Pankaj Chaudhari <[email protected]>
* fix(gh-copilot): parse report metadata from already-read body
The user and enterprise Copilot metrics collectors read the report
metadata HTTP response body with io.ReadAll, then called
parseReportMetadataResponse(res, ...) which read res.Body a second time.
Since the body was already consumed, the second read returned empty,
parseReportMetadata logged "Report metadata response was empty,
skipping" and returned nil, so the collectors produced zero records.
Parse the metadata from the body already read instead. The organization
collector was unaffected because it reads the body only once inline.
Fixes empty _raw_copilot_user_metrics and _raw_copilot_enterprise_metrics
(and downstream _tool_copilot_user_daily_metrics) while org metrics work.
Signed-off-by: Pankaj Chaudhari <[email protected]>
* fix(gh-copilot): resolve migration-linter and golangci-lint failures
- migrationscripts: inline snapshot structs instead of importing models pkg
- user_metrics: fix gofmt struct-tag alignment
- ai_credit_collector/extractor: fix ASF license header (capital The)
- report_download_helper: remove unused
readReportMetadataBody/parseReportMetadataResponse
- metrics_collector_test: retarget tests to
parseReportMetadata/ignoreNoContent
---------
Signed-off-by: Pankaj Chaudhari <[email protected]>
Co-authored-by: Andrei Savu <[email protected]>
---
.../_tool_copilot_enterprise_ai_credit_usage.csv | 4 +
.../_tool_copilot_org_ai_credit_usage.csv | 4 +
.../_tool_copilot_user_ai_credit_usage.csv | 4 +
.../models/enterprise_ai_credit_usage.go | 56 ++++++
.../20260708_add_ai_credit_usage_metrics.go | 162 +++++++++++++++++
.../gh-copilot/models/migrationscripts/register.go | 1 +
backend/plugins/gh-copilot/models/models.go | 4 +
backend/plugins/gh-copilot/models/models_test.go | 3 +
.../gh-copilot/models/org_ai_credit_usage.go | 53 ++++++
.../gh-copilot/models/user_ai_credit_usage.go | 52 ++++++
backend/plugins/gh-copilot/models/user_metrics.go | 17 +-
...metrics_collector.go => ai_credit_collector.go} | 89 ++++------
.../gh-copilot/tasks/ai_credit_extractor.go | 194 +++++++++++++++++++++
.../gh-copilot/tasks/metrics_collector_test.go | 34 +---
backend/plugins/gh-copilot/tasks/register.go | 2 +
.../gh-copilot/tasks/report_download_helper.go | 42 +----
backend/plugins/gh-copilot/tasks/subtasks.go | 17 ++
.../gh-copilot/tasks/user_metrics_collector.go | 11 +-
.../gh-copilot/tasks/user_metrics_extractor.go | 2 +
19 files changed, 618 insertions(+), 133 deletions(-)
diff --git
a/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_ai_credit_usage.csv
b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_ai_credit_usage.csv
new file mode 100644
index 000000000..d9e0e72c2
--- /dev/null
+++
b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_ai_credit_usage.csv
@@ -0,0 +1,4 @@
+connection_id,scope_id,year,month,day,enterprise,model,organization,user,product,cost_center_id,cost_center_name,gross_quantity,discount_quantity,net_quantity,price_per_unit,gross_amount,discount_amount,net_amount
+1,octodemo,2025,12,10,octodemo,gpt-4.1,,,copilot,,,100.5,10.0,90.5,1.0,100.5,10.0,90.5
+1,octodemo,2025,12,10,octodemo,gpt-4o,,,copilot,cc-eng,Engineering,50.0,5.0,45.0,1.0,50.0,5.0,45.0
+1,octodemo,2025,12,10,octodemo,claude-3,,,copilot,cc-ml,Machine
Learning,30.0,2.0,28.0,1.0,30.0,2.0,28.0
diff --git
a/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_ai_credit_usage.csv
b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_ai_credit_usage.csv
new file mode 100644
index 000000000..caf4f77cf
--- /dev/null
+++
b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_ai_credit_usage.csv
@@ -0,0 +1,4 @@
+connection_id,scope_id,year,month,day,organization,model,user,product,gross_quantity,discount_quantity,net_quantity,price_per_unit,gross_amount,discount_amount,net_amount
+1,octodemo,2025,12,10,octodemo,gpt-4.1,alice,copilot,50.0,5.0,45.0,1.0,50.0,5.0,45.0
+1,octodemo,2025,12,10,octodemo,gpt-4o,bob,copilot,30.0,3.0,27.0,1.0,30.0,3.0,27.0
+1,octodemo,2025,12,10,octodemo,claude-3,charlie,copilot,20.0,0.0,20.0,1.0,20.0,0.0,20.0
diff --git
a/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_user_ai_credit_usage.csv
b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_user_ai_credit_usage.csv
new file mode 100644
index 000000000..7e0982734
--- /dev/null
+++
b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_user_ai_credit_usage.csv
@@ -0,0 +1,4 @@
+connection_id,scope_id,year,month,day,user,model,product,gross_quantity,discount_quantity,net_quantity,price_per_unit,gross_amount,discount_amount,net_amount
+1,octodemo,2025,12,10,alice,gpt-4.1,copilot,50.0,5.0,45.0,1.0,50.0,5.0,45.0
+1,octodemo,2025,12,10,alice,gpt-4o,copilot,30.0,3.0,27.0,1.0,30.0,3.0,27.0
+1,octodemo,2025,12,10,bob,claude-3,copilot,20.0,2.0,18.0,1.0,20.0,2.0,18.0
diff --git a/backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go
b/backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go
new file mode 100644
index 000000000..c10814000
--- /dev/null
+++ b/backend/plugins/gh-copilot/models/enterprise_ai_credit_usage.go
@@ -0,0 +1,56 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// GhCopilotEnterpriseAiCreditUsage tracks AI credit consumption at the
enterprise level.
+// One row per time period per model per entity (user, org, or cost center).
+type GhCopilotEnterpriseAiCreditUsage struct {
+ ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"`
+ ScopeId string `gorm:"primaryKey;type:varchar(191)" json:"scopeId"`
+ Year int `gorm:"primaryKey" json:"year"`
+ Month int `gorm:"primaryKey" json:"month"`
+ Day int `gorm:"primaryKey" json:"day"`
+
+ Enterprise string
`gorm:"primaryKey;type:varchar(191);comment:Enterprise slug" json:"enterprise"`
+ Model string `gorm:"primaryKey;type:varchar(191);comment:AI
model name (e.g., gpt-4.1)" json:"model"`
+ Organization string `gorm:"index;type:varchar(255);comment:Organization
within enterprise, if specified" json:"organization"`
+ User string `gorm:"index;type:varchar(255);comment:Username, if
specified" json:"user"`
+
+ Product string `gorm:"type:varchar(32);comment:Product name
(e.g., copilot)" json:"product"`
+ CostCenterId string `gorm:"index;type:varchar(255);comment:Cost
center identifier" json:"costCenterId"`
+ CostCenterName string `gorm:"type:varchar(255);comment:Cost center
display name" json:"costCenterName"`
+
+ // Credit usage breakdown
+ GrossQuantity float64 `json:"grossQuantity" gorm:"comment:Raw
credits consumed"`
+ DiscountQuantity float64 `json:"discountQuantity" gorm:"comment:Credits
discounted"`
+ NetQuantity float64 `json:"netQuantity" gorm:"comment:Credits
after discount"`
+ PricePerUnit float64 `json:"pricePerUnit" gorm:"comment:Price per
credit unit"`
+ GrossAmount float64 `json:"grossAmount" gorm:"comment:Gross cost
before discount"`
+ DiscountAmount float64 `json:"discountAmount" gorm:"comment:Discount
amount"`
+ NetAmount float64 `json:"netAmount" gorm:"comment:Net cost after
discount"`
+
+ common.NoPKModel
+}
+
+func (GhCopilotEnterpriseAiCreditUsage) TableName() string {
+ return "_tool_copilot_enterprise_ai_credit_usage"
+}
diff --git
a/backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go
b/backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go
new file mode 100644
index 000000000..e968fb0dc
--- /dev/null
+++
b/backend/plugins/gh-copilot/models/migrationscripts/20260708_add_ai_credit_usage_metrics.go
@@ -0,0 +1,162 @@
+/*
+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 (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/context"
+ "github.com/apache/incubator-devlake/core/errors"
+
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+)
+
+type addAiCreditUsageMetrics struct{}
+
+// --- Snapshot structs for migration (avoid importing models package to
prevent drift) ---
+
+type creditUsageBreakdown20260708 struct {
+ GrossQuantity float64
+ DiscountQuantity float64
+ NetQuantity float64
+ PricePerUnit float64
+ GrossAmount float64
+ DiscountAmount float64
+ NetAmount float64
+}
+
+type enterpriseAiCreditUsage20260708 struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(191)"`
+ Year int `gorm:"primaryKey"`
+ Month int `gorm:"primaryKey"`
+ Day int `gorm:"primaryKey"`
+
+ Enterprise string `gorm:"primaryKey;type:varchar(191)"`
+ Model string `gorm:"primaryKey;type:varchar(191)"`
+ Organization string `gorm:"index;type:varchar(255)"`
+ User string `gorm:"index;type:varchar(255)"`
+
+ Product string `gorm:"type:varchar(32)"`
+ CostCenterId string `gorm:"index;type:varchar(255)"`
+ CostCenterName string `gorm:"type:varchar(255)"`
+
+ creditUsageBreakdown20260708 `gorm:"embedded"`
+ archived.NoPKModel
+}
+
+func (enterpriseAiCreditUsage20260708) TableName() string {
+ return "_tool_copilot_enterprise_ai_credit_usage"
+}
+
+type orgAiCreditUsage20260708 struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(191)"`
+ Year int `gorm:"primaryKey"`
+ Month int `gorm:"primaryKey"`
+ Day int `gorm:"primaryKey"`
+
+ Organization string `gorm:"primaryKey;type:varchar(191)"`
+ Model string `gorm:"primaryKey;type:varchar(191)"`
+ User string `gorm:"index;type:varchar(255)"`
+
+ Product string `gorm:"type:varchar(32)"`
+
+ creditUsageBreakdown20260708 `gorm:"embedded"`
+ archived.NoPKModel
+}
+
+func (orgAiCreditUsage20260708) TableName() string {
+ return "_tool_copilot_org_ai_credit_usage"
+}
+
+type userAiCreditUsage20260708 struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(191)"`
+ Year int `gorm:"primaryKey"`
+ Month int `gorm:"primaryKey"`
+ Day int `gorm:"primaryKey"`
+
+ User string `gorm:"primaryKey;type:varchar(191)"`
+ Model string `gorm:"primaryKey;type:varchar(191)"`
+
+ Product string `gorm:"type:varchar(32)"`
+
+ creditUsageBreakdown20260708 `gorm:"embedded"`
+ archived.NoPKModel
+}
+
+func (userAiCreditUsage20260708) TableName() string {
+ return "_tool_copilot_user_ai_credit_usage"
+}
+
+// userDailyMetrics20260708 adds AI credit, CLI and code-review columns to the
existing table.
+type userDailyMetrics20260708 struct {
+ ConnectionId uint64 `gorm:"primaryKey"`
+ ScopeId string `gorm:"primaryKey;type:varchar(255)"`
+ Day time.Time `gorm:"primaryKey;type:date"`
+ UserId int64 `gorm:"primaryKey"`
+
+ OrganizationId string `gorm:"type:varchar(100)"`
+ EnterpriseId string `gorm:"type:varchar(100)"`
+ UserLogin string `gorm:"type:varchar(255);index"`
+ UsedAgent bool
+ UsedChat bool
+ UsedCli bool `gorm:"comment:Whether user used
Copilot CLI"`
+ UsedCopilotCodeReviewActive bool `gorm:"comment:Whether user
actively used code review"`
+ UsedCopilotCodeReviewPassive bool `gorm:"comment:Whether user
passively used code review"`
+ AiCreditsUsed float64 `gorm:"comment:AI credits consumed
on this day"`
+
+ UserInitiatedInteractionCount int
+ CodeGenerationActivityCount int
+ CodeAcceptanceActivityCount int
+ LocSuggestedToAddSum int
+ LocSuggestedToDeleteSum int
+ LocAddedSum int
+ LocDeletedSum int
+
+ CliSessionCount int
+ CliRequestCount int
+ CliPromptCount int
+ CliOutputTokenSum int
+ CliPromptTokenSum int
+
+ archived.NoPKModel
+}
+
+func (userDailyMetrics20260708) TableName() string {
+ return "_tool_copilot_user_daily_metrics"
+}
+
+func (u *addAiCreditUsageMetrics) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(
+ basicRes,
+ &enterpriseAiCreditUsage20260708{},
+ &orgAiCreditUsage20260708{},
+ &userAiCreditUsage20260708{},
+ &userDailyMetrics20260708{},
+ )
+}
+
+func (u *addAiCreditUsageMetrics) Version() uint64 {
+ return 20260708000000
+}
+
+func (u *addAiCreditUsageMetrics) Name() string {
+ return "add AI credit usage billing tables"
+}
diff --git a/backend/plugins/gh-copilot/models/migrationscripts/register.go
b/backend/plugins/gh-copilot/models/migrationscripts/register.go
index 399735695..6bf50a371 100644
--- a/backend/plugins/gh-copilot/models/migrationscripts/register.go
+++ b/backend/plugins/gh-copilot/models/migrationscripts/register.go
@@ -31,5 +31,6 @@ func All() []plugin.MigrationScript {
new(addPRFieldsToEnterpriseMetrics),
new(addOrganizationIdToUserMetrics),
new(addCopilotMetricsGaps),
+ new(addAiCreditUsageMetrics),
}
}
diff --git a/backend/plugins/gh-copilot/models/models.go
b/backend/plugins/gh-copilot/models/models.go
index 5143ce5f8..814e45f4a 100644
--- a/backend/plugins/gh-copilot/models/models.go
+++ b/backend/plugins/gh-copilot/models/models.go
@@ -47,5 +47,9 @@ func GetTablesInfo() []dal.Tabler {
&GhCopilotSeat{},
// User-team mappings
&GhCopilotUserTeam{},
+ // AI credit usage billing (enterprise, org, user levels)
+ &GhCopilotEnterpriseAiCreditUsage{},
+ &GhCopilotOrgAiCreditUsage{},
+ &GhCopilotUserAiCreditUsage{},
}
}
diff --git a/backend/plugins/gh-copilot/models/models_test.go
b/backend/plugins/gh-copilot/models/models_test.go
index ef5b3eff6..7e9a2f2e9 100644
--- a/backend/plugins/gh-copilot/models/models_test.go
+++ b/backend/plugins/gh-copilot/models/models_test.go
@@ -41,6 +41,9 @@ func TestGetTablesInfo(t *testing.T) {
(&GhCopilotUserMetricsByModelFeature{}).TableName(): false,
(&GhCopilotSeat{}).TableName(): false,
(&GhCopilotUserTeam{}).TableName(): false,
+ (&GhCopilotEnterpriseAiCreditUsage{}).TableName(): false,
+ (&GhCopilotOrgAiCreditUsage{}).TableName(): false,
+ (&GhCopilotUserAiCreditUsage{}).TableName(): false,
}
if len(tables) != len(expected) {
diff --git a/backend/plugins/gh-copilot/models/org_ai_credit_usage.go
b/backend/plugins/gh-copilot/models/org_ai_credit_usage.go
new file mode 100644
index 000000000..2e079f88f
--- /dev/null
+++ b/backend/plugins/gh-copilot/models/org_ai_credit_usage.go
@@ -0,0 +1,53 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// GhCopilotOrgAiCreditUsage tracks AI credit consumption at the organization
level.
+// One row per time period per model per user (within the organization).
+type GhCopilotOrgAiCreditUsage struct {
+ ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"`
+ ScopeId string `gorm:"primaryKey;type:varchar(191)" json:"scopeId"`
+ Year int `gorm:"primaryKey" json:"year"`
+ Month int `gorm:"primaryKey" json:"month"`
+ Day int `gorm:"primaryKey" json:"day"`
+
+ Organization string
`gorm:"primaryKey;type:varchar(191);comment:Organization name"
json:"organization"`
+ Model string `gorm:"primaryKey;type:varchar(191);comment:AI
model name (e.g., gpt-4.1)" json:"model"`
+ User string `gorm:"index;type:varchar(255);comment:Username
consuming the credits" json:"user"`
+
+ Product string `gorm:"type:varchar(32);comment:Product name (e.g.,
copilot)" json:"product"`
+
+ // Credit usage breakdown
+ GrossQuantity float64 `json:"grossQuantity" gorm:"comment:Raw
credits consumed"`
+ DiscountQuantity float64 `json:"discountQuantity" gorm:"comment:Credits
discounted"`
+ NetQuantity float64 `json:"netQuantity" gorm:"comment:Credits
after discount"`
+ PricePerUnit float64 `json:"pricePerUnit" gorm:"comment:Price per
credit unit"`
+ GrossAmount float64 `json:"grossAmount" gorm:"comment:Gross cost
before discount"`
+ DiscountAmount float64 `json:"discountAmount" gorm:"comment:Discount
amount"`
+ NetAmount float64 `json:"netAmount" gorm:"comment:Net cost after
discount"`
+
+ common.NoPKModel
+}
+
+func (GhCopilotOrgAiCreditUsage) TableName() string {
+ return "_tool_copilot_org_ai_credit_usage"
+}
diff --git a/backend/plugins/gh-copilot/models/user_ai_credit_usage.go
b/backend/plugins/gh-copilot/models/user_ai_credit_usage.go
new file mode 100644
index 000000000..cb1ef2a60
--- /dev/null
+++ b/backend/plugins/gh-copilot/models/user_ai_credit_usage.go
@@ -0,0 +1,52 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// GhCopilotUserAiCreditUsage tracks AI credit consumption at the individual
user level.
+// One row per time period per model per authenticated user.
+type GhCopilotUserAiCreditUsage struct {
+ ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"`
+ ScopeId string `gorm:"primaryKey;type:varchar(191)" json:"scopeId"`
+ Year int `gorm:"primaryKey" json:"year"`
+ Month int `gorm:"primaryKey" json:"month"`
+ Day int `gorm:"primaryKey" json:"day"`
+
+ User string `gorm:"primaryKey;type:varchar(191);comment:GitHub
username" json:"user"`
+ Model string `gorm:"primaryKey;type:varchar(191);comment:AI model name
(e.g., gpt-4.1)" json:"model"`
+
+ Product string `gorm:"type:varchar(32);comment:Product name (e.g.,
copilot)" json:"product"`
+
+ // Credit usage breakdown
+ GrossQuantity float64 `json:"grossQuantity" gorm:"comment:Raw
credits consumed"`
+ DiscountQuantity float64 `json:"discountQuantity" gorm:"comment:Credits
discounted"`
+ NetQuantity float64 `json:"netQuantity" gorm:"comment:Credits
after discount"`
+ PricePerUnit float64 `json:"pricePerUnit" gorm:"comment:Price per
credit unit"`
+ GrossAmount float64 `json:"grossAmount" gorm:"comment:Gross cost
before discount"`
+ DiscountAmount float64 `json:"discountAmount" gorm:"comment:Discount
amount"`
+ NetAmount float64 `json:"netAmount" gorm:"comment:Net cost after
discount"`
+
+ common.NoPKModel
+}
+
+func (GhCopilotUserAiCreditUsage) TableName() string {
+ return "_tool_copilot_user_ai_credit_usage"
+}
diff --git a/backend/plugins/gh-copilot/models/user_metrics.go
b/backend/plugins/gh-copilot/models/user_metrics.go
index 18e9134c2..0a16b3bce 100644
--- a/backend/plugins/gh-copilot/models/user_metrics.go
+++ b/backend/plugins/gh-copilot/models/user_metrics.go
@@ -30,14 +30,15 @@ type GhCopilotUserDailyMetrics struct {
Day time.Time `gorm:"primaryKey;type:date" json:"day"`
UserId int64 `gorm:"primaryKey" json:"userId"`
- OrganizationId string `json:"organizationId"
gorm:"type:varchar(100)"`
- EnterpriseId string `json:"enterpriseId"
gorm:"type:varchar(100)"`
- UserLogin string `json:"userLogin"
gorm:"type:varchar(255);index"`
- UsedAgent bool `json:"usedAgent"`
- UsedChat bool `json:"usedChat"`
- UsedCli bool `json:"usedCli"
gorm:"comment:Whether user used Copilot CLI"`
- UsedCopilotCodeReviewActive bool `json:"usedCopilotCodeReviewActive"
gorm:"comment:Whether user actively used code review"`
- UsedCopilotCodeReviewPassive bool
`json:"usedCopilotCodeReviewPassive" gorm:"comment:Whether user passively used
code review"`
+ OrganizationId string `json:"organizationId"
gorm:"type:varchar(100)"`
+ EnterpriseId string `json:"enterpriseId"
gorm:"type:varchar(100)"`
+ UserLogin string `json:"userLogin"
gorm:"type:varchar(255);index"`
+ UsedAgent bool `json:"usedAgent"`
+ UsedChat bool `json:"usedChat"`
+ UsedCli bool `json:"usedCli"
gorm:"comment:Whether user used Copilot CLI"`
+ UsedCopilotCodeReviewActive bool
`json:"usedCopilotCodeReviewActive" gorm:"comment:Whether user actively used
code review"`
+ UsedCopilotCodeReviewPassive bool
`json:"usedCopilotCodeReviewPassive" gorm:"comment:Whether user passively used
code review"`
+ AiCreditsUsed float64 `json:"aiCreditsUsed"
gorm:"comment:AI credits consumed on this day"`
CopilotActivityMetrics `mapstructure:",squash"`
CopilotCliMetrics `mapstructure:",squash"`
diff --git a/backend/plugins/gh-copilot/tasks/user_metrics_collector.go
b/backend/plugins/gh-copilot/tasks/ai_credit_collector.go
similarity index 55%
copy from backend/plugins/gh-copilot/tasks/user_metrics_collector.go
copy to backend/plugins/gh-copilot/tasks/ai_credit_collector.go
index 8198b48a6..f9dd4d75b 100644
--- a/backend/plugins/gh-copilot/tasks/user_metrics_collector.go
+++ b/backend/plugins/gh-copilot/tasks/ai_credit_collector.go
@@ -19,66 +19,20 @@ package tasks
import (
"encoding/json"
- "io"
+ "fmt"
"net/http"
"net/url"
+ "strconv"
"time"
"github.com/apache/incubator-devlake/core/errors"
- "github.com/apache/incubator-devlake/core/log"
"github.com/apache/incubator-devlake/core/plugin"
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
)
-const rawUserMetricsTable = "copilot_user_metrics"
-
-func collectUserMetricsRecords(meta *reportMetadataResponse, logger
log.Logger) ([]json.RawMessage, errors.Error) {
- var results []json.RawMessage
- for _, link := range meta.DownloadLinks {
- reportBody, dlErr := downloadReport(link, logger)
- if dlErr != nil {
- return nil, dlErr
- }
- if reportBody == nil {
- continue // blob not found, skip
- }
- // Parse JSONL: split by newlines and return each non-empty
line.
- userRecords, parseErr := parseJSONL(reportBody)
- if parseErr != nil {
- return nil, parseErr
- }
- results = append(results, userRecords...)
- }
- return results, nil
-}
-
-func parseUserMetricsReportResponse(res *http.Response, logger log.Logger)
([]json.RawMessage, errors.Error) {
- body, readErr := io.ReadAll(res.Body)
- res.Body.Close()
- if readErr != nil {
- return nil, errors.Default.Wrap(readErr, "failed to read report
metadata")
- }
- if isEmptyReport(body) {
- return nil, nil
- }
-
- var meta *reportMetadataResponse
- if jsonErr := json.Unmarshal(body, &meta); jsonErr != nil {
- return nil, errors.Default.Wrap(jsonErr, "failed to parse
report metadata")
- }
-
- meta, err := parseReportMetadataResponse(res, logger)
- if err != nil || meta == nil {
- return nil, err
- }
+const rawAiCreditUsageTable = "copilot_ai_credit_usage"
- return collectUserMetricsRecords(meta, logger)
-}
-
-// CollectUserMetrics collects enterprise user-level daily Copilot usage
reports.
-// These reports are in JSONL format (one JSON object per line per user).
-// Utilizes the enterprise or organization endpoints depending on connection
configuration
-func CollectUserMetrics(taskCtx plugin.SubTaskContext) errors.Error {
+func CollectAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error {
data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData)
if !ok {
return errors.Default.New("task data is not GhCopilotTaskData")
@@ -92,18 +46,22 @@ func CollectUserMetrics(taskCtx plugin.SubTaskContext)
errors.Error {
}
var urlTemplate string
+ var scope string
if connection.HasEnterprise() {
- urlTemplate = copilotAPIPath("enterprises",
connection.Enterprise, "copilot/metrics/reports/users-1-day")
+ urlTemplate =
fmt.Sprintf("enterprises/%s/settings/billing/ai_credit/usage",
connection.Enterprise)
+ scope = connection.Enterprise
} else if connection.Organization != "" {
- urlTemplate = copilotAPIPath("orgs", connection.Organization,
"copilot/metrics/reports/users-1-day")
+ urlTemplate =
fmt.Sprintf("organizations/%s/settings/billing/ai_credit/usage",
connection.Organization)
+ scope = connection.Organization
} else {
- return nil
+ urlTemplate = "user/settings/billing/ai_credit/usage"
+ scope = "user"
}
rawArgs := helper.RawDataSubTaskArgs{
Ctx: taskCtx,
- Table: rawUserMetricsTable,
+ Table: rawAiCreditUsageTable,
Options: copilotRawParams{
ConnectionId: data.Options.ConnectionId,
ScopeId: data.Options.ScopeId,
@@ -120,7 +78,6 @@ func CollectUserMetrics(taskCtx plugin.SubTaskContext)
errors.Error {
now := time.Now().UTC()
start, until := computeReportDateRange(now, collector.GetSince())
start = clampDailyMetricsStartForBackfill(start, until)
- logger := taskCtx.GetLogger()
dayIter := newDayIterator(start, until)
@@ -130,19 +87,37 @@ func CollectUserMetrics(taskCtx plugin.SubTaskContext)
errors.Error {
UrlTemplate: urlTemplate,
Query: func(reqData *helper.RequestData) (url.Values,
errors.Error) {
input := reqData.Input.(*dayInput)
+ day, parseErr := time.Parse("2006-01-02", input.Day)
+ if parseErr != nil {
+ return nil, errors.Convert(parseErr)
+ }
q := url.Values{}
- q.Set("day", input.Day)
+ q.Set("year", strconv.Itoa(day.Year()))
+ q.Set("month", strconv.Itoa(int(day.Month())))
+ q.Set("day", strconv.Itoa(day.Day()))
return q, nil
},
Incremental: true,
Concurrency: 1,
AfterResponse: ignoreNoContent,
ResponseParser: func(res *http.Response) ([]json.RawMessage,
errors.Error) {
- return parseUserMetricsReportResponse(res, logger)
+ if res.StatusCode != http.StatusOK {
+ return nil,
errors.HttpStatus(res.StatusCode).New(fmt.Sprintf("failed to collect AI credit
usage for %s", scope))
+ }
+
+ var response struct {
+ UsageItems []json.RawMessage `json:"usageItems"`
+ }
+ if unmErr := helper.UnmarshalResponse(res, &response);
unmErr != nil {
+ return nil, unmErr
+ }
+
+ return response.UsageItems, nil
},
})
if err != nil {
return err
}
+
return collector.Execute()
}
diff --git a/backend/plugins/gh-copilot/tasks/ai_credit_extractor.go
b/backend/plugins/gh-copilot/tasks/ai_credit_extractor.go
new file mode 100644
index 000000000..7d372db26
--- /dev/null
+++ b/backend/plugins/gh-copilot/tasks/ai_credit_extractor.go
@@ -0,0 +1,194 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/plugins/gh-copilot/models"
+)
+
+// aiCreditUsageRecord represents a single usage item from the AI credit usage
API.
+type aiCreditUsageRecord struct {
+ Product string `json:"product"`
+ Sku string `json:"sku"`
+ Model string `json:"model"`
+ UnitType string `json:"unitType"`
+ PricePerUnit float64 `json:"pricePerUnit"`
+ GrossQuantity float64 `json:"grossQuantity"`
+ DiscountQuantity float64 `json:"discountQuantity"`
+ NetQuantity float64 `json:"netQuantity"`
+ GrossAmount float64 `json:"grossAmount"`
+ DiscountAmount float64 `json:"discountAmount"`
+ NetAmount float64 `json:"netAmount"`
+}
+
+// aiCreditResponseWrapper represents the wrapper around the API response
containing time period and usage items.
+type aiCreditResponseWrapper struct {
+ TimePeriod struct {
+ Year int `json:"year"`
+ Month int `json:"month"`
+ Day int `json:"day"`
+ } `json:"timePeriod"`
+ Enterprise string `json:"enterprise"`
+ Organization string `json:"organization"`
+ User string `json:"user"`
+ Product string `json:"product"`
+ Model string `json:"model"`
+ CostCenter struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ } `json:"costCenter"`
+}
+
+// ExtractAiCreditUsage parses AI credit usage records into the appropriate
model tables.
+func ExtractAiCreditUsage(taskCtx plugin.SubTaskContext) errors.Error {
+ data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData)
+ if !ok {
+ return errors.Default.New("task data is not GhCopilotTaskData")
+ }
+ connection := data.Connection
+ connection.Normalize()
+
+ extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{
+ RawDataSubTaskArgs: helper.RawDataSubTaskArgs{
+ Ctx: taskCtx,
+ Table: rawAiCreditUsageTable,
+ Options: copilotRawParams{
+ ConnectionId: data.Options.ConnectionId,
+ ScopeId: data.Options.ScopeId,
+ Organization: connection.Organization,
+ Endpoint: connection.Endpoint,
+ },
+ },
+ Extract: func(row *helper.RawData) ([]interface{},
errors.Error) {
+ // Parse raw data
+ var record aiCreditUsageRecord
+ err := json.Unmarshal(row.Data, &record)
+ if err != nil {
+ return nil, errors.Convert(err)
+ }
+
+ // Extract wrapper info from row context
+ var wrapper aiCreditResponseWrapper
+ if connection.HasEnterprise() {
+ wrapper.Enterprise = connection.Enterprise
+ } else if connection.Organization != "" {
+ wrapper.Organization = connection.Organization
+ }
+ wrapper.Product = record.Product
+ wrapper.Model = record.Model
+
+ // Derive the time period from the collector's day
input so records are
+ // deterministic and aligned with the requested billing
day, rather than
+ // depending on the extraction-time clock.
+ var input dayInput
+ if len(row.Input) > 0 {
+ if unmErr := json.Unmarshal(row.Input, &input);
unmErr != nil {
+ return nil, errors.Convert(unmErr)
+ }
+ }
+ day, parseErr := time.Parse("2006-01-02", input.Day)
+ if parseErr != nil {
+ return nil, errors.Convert(parseErr)
+ }
+ wrapper.TimePeriod.Year = day.Year()
+ wrapper.TimePeriod.Month = int(day.Month())
+ wrapper.TimePeriod.Day = day.Day()
+
+ var results []interface{}
+
+ // Route to appropriate table based on connection type
+ if connection.HasEnterprise() {
+ toolRecord :=
&models.GhCopilotEnterpriseAiCreditUsage{
+ ConnectionId: data.Connection.ID,
+ ScopeId: data.Options.ScopeId,
+ Year:
wrapper.TimePeriod.Year,
+ Month:
wrapper.TimePeriod.Month,
+ Day:
wrapper.TimePeriod.Day,
+ Enterprise: wrapper.Enterprise,
+ Model: record.Model,
+ Organization: wrapper.Organization,
+ User: wrapper.User,
+ Product: record.Product,
+ CostCenterId: wrapper.CostCenter.Id,
+ CostCenterName:
wrapper.CostCenter.Name,
+ GrossQuantity: record.GrossQuantity,
+ DiscountQuantity:
record.DiscountQuantity,
+ NetQuantity: record.NetQuantity,
+ PricePerUnit: record.PricePerUnit,
+ GrossAmount: record.GrossAmount,
+ DiscountAmount: record.DiscountAmount,
+ NetAmount: record.NetAmount,
+ }
+ results = append(results, toolRecord)
+ } else if connection.Organization != "" {
+ toolRecord := &models.GhCopilotOrgAiCreditUsage{
+ ConnectionId: data.Connection.ID,
+ ScopeId: data.Options.ScopeId,
+ Year:
wrapper.TimePeriod.Year,
+ Month:
wrapper.TimePeriod.Month,
+ Day:
wrapper.TimePeriod.Day,
+ Organization: wrapper.Organization,
+ Model: record.Model,
+ User: wrapper.User,
+ Product: record.Product,
+ GrossQuantity: record.GrossQuantity,
+ DiscountQuantity:
record.DiscountQuantity,
+ NetQuantity: record.NetQuantity,
+ PricePerUnit: record.PricePerUnit,
+ GrossAmount: record.GrossAmount,
+ DiscountAmount: record.DiscountAmount,
+ NetAmount: record.NetAmount,
+ }
+ results = append(results, toolRecord)
+ } else {
+ // User-level credits
+ toolRecord :=
&models.GhCopilotUserAiCreditUsage{
+ ConnectionId: data.Connection.ID,
+ ScopeId: data.Options.ScopeId,
+ Year:
wrapper.TimePeriod.Year,
+ Month:
wrapper.TimePeriod.Month,
+ Day:
wrapper.TimePeriod.Day,
+ User: connection.Name, //
Use connection display name
+ Model: record.Model,
+ Product: record.Product,
+ GrossQuantity: record.GrossQuantity,
+ DiscountQuantity:
record.DiscountQuantity,
+ NetQuantity: record.NetQuantity,
+ PricePerUnit: record.PricePerUnit,
+ GrossAmount: record.GrossAmount,
+ DiscountAmount: record.DiscountAmount,
+ NetAmount: record.NetAmount,
+ }
+ results = append(results, toolRecord)
+ }
+
+ return results, nil
+ },
+ })
+ if err != nil {
+ return err
+ }
+
+ return extractor.Execute()
+}
diff --git a/backend/plugins/gh-copilot/tasks/metrics_collector_test.go
b/backend/plugins/gh-copilot/tasks/metrics_collector_test.go
index d71d20d44..88214af6d 100644
--- a/backend/plugins/gh-copilot/tasks/metrics_collector_test.go
+++ b/backend/plugins/gh-copilot/tasks/metrics_collector_test.go
@@ -18,12 +18,11 @@ limitations under the License.
package tasks
import (
- "bytes"
- "io"
"net/http"
"testing"
"time"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/stretchr/testify/require"
)
@@ -109,35 +108,20 @@ func
TestUserMetricsDateRangeAppliesFourDayBackfillWindow(t *testing.T) {
require.Equal(t, time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC), start)
}
-func TestParseReportMetadataResponseNoContent(t *testing.T) {
- res := &http.Response{
- StatusCode: http.StatusNoContent,
- Body: io.NopCloser(bytes.NewReader(nil)),
- }
-
- meta, err := parseReportMetadataResponse(res, nil)
- require.NoError(t, err)
- require.Nil(t, meta)
+func TestIgnoreNoContentSkips204And404(t *testing.T) {
+ require.Equal(t, helper.ErrIgnoreAndContinue,
ignoreNoContent(&http.Response{StatusCode: http.StatusNoContent}))
+ require.Equal(t, helper.ErrIgnoreAndContinue,
ignoreNoContent(&http.Response{StatusCode: http.StatusNotFound}))
+ require.NoError(t, ignoreNoContent(&http.Response{StatusCode:
http.StatusOK}))
}
-func TestParseReportMetadataResponseEmptyBody(t *testing.T) {
- res := &http.Response{
- StatusCode: http.StatusOK,
- Body: io.NopCloser(bytes.NewReader(nil)),
- }
-
- meta, err := parseReportMetadataResponse(res, nil)
+func TestParseReportMetadataEmptyBody(t *testing.T) {
+ meta, err := parseReportMetadata(nil, nil)
require.NoError(t, err)
require.Nil(t, meta)
}
-func TestParseReportMetadataResponseEmptyString(t *testing.T) {
- res := &http.Response{
- StatusCode: http.StatusOK,
- Body: io.NopCloser(bytes.NewReader([]byte(`""`))),
- }
-
- meta, err := parseReportMetadataResponse(res, nil)
+func TestParseReportMetadataEmptyString(t *testing.T) {
+ meta, err := parseReportMetadata([]byte(`""`), nil)
require.NoError(t, err)
require.Nil(t, meta)
}
diff --git a/backend/plugins/gh-copilot/tasks/register.go
b/backend/plugins/gh-copilot/tasks/register.go
index 3c7e5b1ee..23d592304 100644
--- a/backend/plugins/gh-copilot/tasks/register.go
+++ b/backend/plugins/gh-copilot/tasks/register.go
@@ -28,11 +28,13 @@ func GetSubTaskMetas() []plugin.SubTaskMeta {
CollectEnterpriseMetricsMeta,
CollectUserMetricsMeta,
CollectUserTeamsMeta,
+ CollectAiCreditUsageMeta,
// Extractors
ExtractSeatsMeta,
ExtractOrgMetricsMeta,
ExtractEnterpriseMetricsMeta,
ExtractUserMetricsMeta,
ExtractUserTeamsMeta,
+ ExtractAiCreditUsageMeta,
}
}
diff --git a/backend/plugins/gh-copilot/tasks/report_download_helper.go
b/backend/plugins/gh-copilot/tasks/report_download_helper.go
index 236b03988..280e418e3 100644
--- a/backend/plugins/gh-copilot/tasks/report_download_helper.go
+++ b/backend/plugins/gh-copilot/tasks/report_download_helper.go
@@ -95,15 +95,6 @@ type reportMetadataResponse struct {
ReportEndDay string `json:"report_end_day"`
}
-func readReportMetadataBody(res *http.Response) ([]byte, errors.Error) {
- body, readErr := io.ReadAll(res.Body)
- res.Body.Close()
- if readErr != nil {
- return nil, errors.Default.Wrap(readErr, "failed to read report
metadata")
- }
- return body, nil
-}
-
func logReportMetadataParseError(body []byte, err error, logger log.Logger) {
if logger == nil {
return
@@ -165,23 +156,6 @@ func parseReportMetadata(body []byte, logger log.Logger)
(*reportMetadataRespons
return &meta, nil
}
-func parseReportMetadataResponse(res *http.Response, logger log.Logger)
(*reportMetadataResponse, errors.Error) {
- if res.StatusCode == http.StatusNoContent {
- if logger != nil {
- logger.Info("Report metadata not ready yet (204),
skipping for now")
- }
- res.Body.Close()
- return nil, nil
- }
-
- body, readErr := readReportMetadataBody(res)
- if readErr != nil {
- return nil, readErr
- }
-
- return parseReportMetadata(body, logger)
-}
-
func collectRawReportRecords(meta *reportMetadataResponse, logger log.Logger)
([]json.RawMessage, errors.Error) {
if len(meta.DownloadLinks) == 0 {
logger.Info("No download links for report day=%s, skipping",
meta.ReportDay)
@@ -212,17 +186,11 @@ func parseRawReportResponse(res *http.Response, logger
log.Logger) ([]json.RawMe
return nil, nil
}
- var meta *reportMetadataResponse
- if jsonErr := json.Unmarshal(body, &meta); jsonErr != nil {
- snippet := string(body)
- if len(snippet) > 200 {
- snippet = snippet[:200]
- }
- logger.Error(jsonErr, "failed to parse report metadata,
body=%s", snippet)
- return nil, errors.Default.Wrap(jsonErr, "failed to parse
report metadata")
- }
-
- meta, err := parseReportMetadataResponse(res, logger)
+ // Parse the metadata from the body we already read above. Previously
this
+ // re-read res.Body via parseReportMetadataResponse, but the body had
already
+ // been consumed by io.ReadAll, so the second read returned empty and
the
+ // collector silently produced zero records (affecting enterprise
metrics).
+ meta, err := parseReportMetadata(body, logger)
if err != nil || meta == nil {
return nil, err
}
diff --git a/backend/plugins/gh-copilot/tasks/subtasks.go
b/backend/plugins/gh-copilot/tasks/subtasks.go
index 61ed57995..692552870 100644
--- a/backend/plugins/gh-copilot/tasks/subtasks.go
+++ b/backend/plugins/gh-copilot/tasks/subtasks.go
@@ -105,3 +105,20 @@ var ExtractUserTeamsMeta = plugin.SubTaskMeta{
Description: "Extract Copilot user-team mappings into tool-layer
table",
Dependencies: []*plugin.SubTaskMeta{&CollectUserTeamsMeta},
}
+
+var CollectAiCreditUsageMeta = plugin.SubTaskMeta{
+ Name: "collectAiCreditUsage",
+ EntryPoint: CollectAiCreditUsage,
+ EnabledByDefault: true,
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
+ Description: "Collect GitHub Copilot AI credit usage billing data
from billing API",
+}
+
+var ExtractAiCreditUsageMeta = plugin.SubTaskMeta{
+ Name: "extractAiCreditUsage",
+ EntryPoint: ExtractAiCreditUsage,
+ EnabledByDefault: true,
+ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS},
+ Description: "Extract Copilot AI credit usage into tool-layer
billing tables",
+ Dependencies: []*plugin.SubTaskMeta{&CollectAiCreditUsageMeta},
+}
diff --git a/backend/plugins/gh-copilot/tasks/user_metrics_collector.go
b/backend/plugins/gh-copilot/tasks/user_metrics_collector.go
index 8198b48a6..d24db69f4 100644
--- a/backend/plugins/gh-copilot/tasks/user_metrics_collector.go
+++ b/backend/plugins/gh-copilot/tasks/user_metrics_collector.go
@@ -62,12 +62,11 @@ func parseUserMetricsReportResponse(res *http.Response,
logger log.Logger) ([]js
return nil, nil
}
- var meta *reportMetadataResponse
- if jsonErr := json.Unmarshal(body, &meta); jsonErr != nil {
- return nil, errors.Default.Wrap(jsonErr, "failed to parse
report metadata")
- }
-
- meta, err := parseReportMetadataResponse(res, logger)
+ // Parse the metadata from the body we already read above. Previously
this
+ // re-read res.Body via parseReportMetadataResponse, but the body had
already
+ // been consumed by io.ReadAll, so the second read returned empty and
the
+ // collector silently produced zero user-metrics records.
+ meta, err := parseReportMetadata(body, logger)
if err != nil || meta == nil {
return nil, err
}
diff --git a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go
b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go
index 729921940..7671fab86 100644
--- a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go
+++ b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go
@@ -49,6 +49,7 @@ type userDailyReport struct {
UsedCli bool `json:"used_cli"`
UsedCopilotCodeReviewActive bool
`json:"used_copilot_code_review_active"`
UsedCopilotCodeReviewPassive bool
`json:"used_copilot_code_review_passive"`
+ AiCreditsUsed float64
`json:"ai_credits_used"`
TotalsByIde []userTotalsByIde
`json:"totals_by_ide"`
TotalsByFeature []totalsByFeature
`json:"totals_by_feature"`
TotalsByLanguageFeature []totalsByLangFeature
`json:"totals_by_language_feature"`
@@ -123,6 +124,7 @@ func ExtractUserMetrics(taskCtx plugin.SubTaskContext)
errors.Error {
UsedCli: u.UsedCli,
UsedCopilotCodeReviewActive:
u.UsedCopilotCodeReviewActive,
UsedCopilotCodeReviewPassive:
u.UsedCopilotCodeReviewPassive,
+ AiCreditsUsed: u.AiCreditsUsed,
CopilotActivityMetrics:
models.CopilotActivityMetrics{
UserInitiatedInteractionCount:
u.UserInitiatedInteractionCount,
CodeGenerationActivityCount:
u.CodeGenerationActivityCount,