feat(coderd): add PR status summary to telemetry snapshots (#24379)

Adds aggregate PR counts (total, open, merged, closed) from
`chat_diff_statuses` to telemetry snapshots, giving visibility into AI
agent PR outcomes across deployments.

The existing telemetry system reports `Chats`, `ChatMessageSummaries`,
and `ChatModelConfigs`, but had no PR-level data. This adds a
`ChatDiffStatusSummary` field to the `Snapshot` struct with four
all-time counts derived from a single aggregate query.

<details>
<summary>Implementation details</summary>

- New SQL query `GetChatDiffStatusSummary` counts `chat_diff_statuses`
rows with non-NULL `pull_request_state`, grouped by state
(open/merged/closed).
- `ChatDiffStatusSummary` struct added to telemetry `Snapshot`,
collected via a parallel `eg.Go()` block in `createSnapshot()`.
- `dbauthz` wrapper uses `rbac.ResourceSystem` (telemetry-only pattern).
- Test covers both empty state (zero counts) and populated state (mixed
states + NULL-state exclusion).

</details>

> 🤖 Generated by Coder Agents
This commit is contained in:
Dean Sheather
2026-04-17 21:56:11 +10:00
committed by GitHub
parent db8191277b
commit 4ba74dcdc8
9 changed files with 291 additions and 12 deletions
+8
View File
@@ -2658,6 +2658,14 @@ func (q *querier) GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUI
return q.db.GetChatDiffStatusByChatID(ctx, chatID)
}
func (q *querier) GetChatDiffStatusSummary(ctx context.Context) (database.GetChatDiffStatusSummaryRow, error) {
// Telemetry queries are called from system contexts only.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return database.GetChatDiffStatusSummaryRow{}, err
}
return q.db.GetChatDiffStatusSummary(ctx)
}
func (q *querier) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIDs []uuid.UUID) ([]database.ChatDiffStatus, error) {
if len(chatIDs) == 0 {
return []database.ChatDiffStatus{}, nil
+4
View File
@@ -4192,6 +4192,10 @@ func (s *MethodTestSuite) TestSystemFunctions() {
dbm.EXPECT().GetChatMessageSummariesPerChat(gomock.Any(), ts).Return([]database.GetChatMessageSummariesPerChatRow{}, nil).AnyTimes()
check.Args(ts).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("GetChatDiffStatusSummary", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatDiffStatusSummary(gomock.Any()).Return(database.GetChatDiffStatusSummaryRow{}, nil).AnyTimes()
check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("GetChatModelConfigsForTelemetry", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatModelConfigsForTelemetry(gomock.Any()).Return([]database.GetChatModelConfigsForTelemetryRow{}, nil).AnyTimes()
check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead)
@@ -1200,6 +1200,14 @@ func (m queryMetricsStore) GetChatDiffStatusByChatID(ctx context.Context, chatID
return r0, r1
}
func (m queryMetricsStore) GetChatDiffStatusSummary(ctx context.Context) (database.GetChatDiffStatusSummaryRow, error) {
start := time.Now()
r0, r1 := m.s.GetChatDiffStatusSummary(ctx)
m.queryLatencies.WithLabelValues("GetChatDiffStatusSummary").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDiffStatusSummary").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIDs []uuid.UUID) ([]database.ChatDiffStatus, error) {
start := time.Now()
r0, r1 := m.s.GetChatDiffStatusesByChatIDs(ctx, chatIDs)
+15
View File
@@ -2207,6 +2207,21 @@ func (mr *MockStoreMockRecorder) GetChatDiffStatusByChatID(ctx, chatID any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDiffStatusByChatID", reflect.TypeOf((*MockStore)(nil).GetChatDiffStatusByChatID), ctx, chatID)
}
// GetChatDiffStatusSummary mocks base method.
func (m *MockStore) GetChatDiffStatusSummary(ctx context.Context) (database.GetChatDiffStatusSummaryRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatDiffStatusSummary", ctx)
ret0, _ := ret[0].(database.GetChatDiffStatusSummaryRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatDiffStatusSummary indicates an expected call of GetChatDiffStatusSummary.
func (mr *MockStoreMockRecorder) GetChatDiffStatusSummary(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDiffStatusSummary", reflect.TypeOf((*MockStore)(nil).GetChatDiffStatusSummary), ctx)
}
// GetChatDiffStatusesByChatIDs mocks base method.
func (m *MockStore) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]database.ChatDiffStatus, error) {
m.ctrl.T.Helper()
+7
View File
@@ -281,6 +281,13 @@ type sqlcQuerier interface {
GetChatDebugStepsByRunID(ctx context.Context, runID uuid.UUID) ([]ChatDebugStep, error)
GetChatDesktopEnabled(ctx context.Context) (bool, error)
GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (ChatDiffStatus, error)
// Returns aggregate PR counts across all agent chats for telemetry.
// Deduplicates by PR URL so forked chats referencing the same pull
// request are counted once (using the most recently refreshed state).
// Total is derived from the three recognized state buckets and
// always equals open + merged + closed; other non-NULL states are
// intentionally excluded from these aggregates.
GetChatDiffStatusSummary(ctx context.Context) (GetChatDiffStatusSummaryRow, error)
GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error)
GetChatExploreModelOverride(ctx context.Context) (string, error)
GetChatFileByID(ctx context.Context, id uuid.UUID) (ChatFile, error)
+52 -6
View File
@@ -5809,6 +5809,48 @@ func (q *sqlQuerier) GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.
return i, err
}
const getChatDiffStatusSummary = `-- name: GetChatDiffStatusSummary :one
WITH deduped AS (
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
cds.pull_request_state
FROM chat_diff_statuses cds
JOIN chats c ON c.id = cds.chat_id
WHERE cds.pull_request_state IN ('open', 'merged', 'closed')
ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), cds.updated_at DESC, c.id DESC
)
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE pull_request_state = 'open')::bigint AS open,
COUNT(*) FILTER (WHERE pull_request_state = 'merged')::bigint AS merged,
COUNT(*) FILTER (WHERE pull_request_state = 'closed')::bigint AS closed
FROM deduped
`
type GetChatDiffStatusSummaryRow struct {
Total int64 `db:"total" json:"total"`
Open int64 `db:"open" json:"open"`
Merged int64 `db:"merged" json:"merged"`
Closed int64 `db:"closed" json:"closed"`
}
// Returns aggregate PR counts across all agent chats for telemetry.
// Deduplicates by PR URL so forked chats referencing the same pull
// request are counted once (using the most recently refreshed state).
// Total is derived from the three recognized state buckets and
// always equals open + merged + closed; other non-NULL states are
// intentionally excluded from these aggregates.
func (q *sqlQuerier) GetChatDiffStatusSummary(ctx context.Context) (GetChatDiffStatusSummaryRow, error) {
row := q.db.QueryRowContext(ctx, getChatDiffStatusSummary)
var i GetChatDiffStatusSummaryRow
err := row.Scan(
&i.Total,
&i.Open,
&i.Merged,
&i.Closed,
)
return i, err
}
const getChatDiffStatusesByChatIDs = `-- name: GetChatDiffStatusesByChatIDs :many
SELECT
chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count, head_branch
@@ -6621,12 +6663,14 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID
const getChatsUpdatedAfter = `-- name: GetChatsUpdatedAfter :many
SELECT
id, owner_id, created_at, updated_at, status,
(parent_chat_id IS NOT NULL)::bool AS has_parent,
root_chat_id, workspace_id,
mode, archived, last_model_config_id, client_type
FROM chats
WHERE updated_at > $1
c.id, c.owner_id, c.created_at, c.updated_at, c.status,
(c.parent_chat_id IS NOT NULL)::bool AS has_parent,
c.root_chat_id, c.workspace_id,
c.mode, c.archived, c.last_model_config_id, c.client_type,
cds.pull_request_state
FROM chats c
LEFT JOIN chat_diff_statuses cds ON cds.chat_id = c.id
WHERE c.updated_at > $1
`
type GetChatsUpdatedAfterRow struct {
@@ -6642,6 +6686,7 @@ type GetChatsUpdatedAfterRow struct {
Archived bool `db:"archived" json:"archived"`
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
ClientType ChatClientType `db:"client_type" json:"client_type"`
PullRequestState sql.NullString `db:"pull_request_state" json:"pull_request_state"`
}
// Retrieves chats updated after the given timestamp for telemetry
@@ -6669,6 +6714,7 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time
&i.Archived,
&i.LastModelConfigID,
&i.ClientType,
&i.PullRequestState,
); err != nil {
return nil, err
}
+30 -6
View File
@@ -934,6 +934,28 @@ SET
WHERE
chat_id = @chat_id::uuid;
-- name: GetChatDiffStatusSummary :one
-- Returns aggregate PR counts across all agent chats for telemetry.
-- Deduplicates by PR URL so forked chats referencing the same pull
-- request are counted once (using the most recently refreshed state).
-- Total is derived from the three recognized state buckets and
-- always equals open + merged + closed; other non-NULL states are
-- intentionally excluded from these aggregates.
WITH deduped AS (
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
cds.pull_request_state
FROM chat_diff_statuses cds
JOIN chats c ON c.id = cds.chat_id
WHERE cds.pull_request_state IN ('open', 'merged', 'closed')
ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), cds.updated_at DESC, c.id DESC
)
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE pull_request_state = 'open')::bigint AS open,
COUNT(*) FILTER (WHERE pull_request_state = 'merged')::bigint AS merged,
COUNT(*) FILTER (WHERE pull_request_state = 'closed')::bigint AS closed
FROM deduped;
-- name: GetChatCostSummary :one
-- Aggregate cost summary for a single user within a date range.
-- Only counts assistant-role messages.
@@ -1298,12 +1320,14 @@ WHERE chats.id = deletable.id
-- snapshot collection. Uses updated_at so that long-running chats
-- still appear in each snapshot window while they are active.
SELECT
id, owner_id, created_at, updated_at, status,
(parent_chat_id IS NOT NULL)::bool AS has_parent,
root_chat_id, workspace_id,
mode, archived, last_model_config_id, client_type
FROM chats
WHERE updated_at > @updated_after;
c.id, c.owner_id, c.created_at, c.updated_at, c.status,
(c.parent_chat_id IS NOT NULL)::bool AS has_parent,
c.root_chat_id, c.workspace_id,
c.mode, c.archived, c.last_model_config_id, c.client_type,
cds.pull_request_state
FROM chats c
LEFT JOIN chat_diff_statuses cds ON cds.chat_id = c.id
WHERE c.updated_at > @updated_after;
-- name: GetChatMessageSummariesPerChat :many
-- Aggregates message-level metrics per chat for messages created