feat: add telemetry for task lifecycle events (#21922)

Relates to https://github.com/coder/internal/issues/1259

Adds new database queries and telemetry collection functions to gather
task lifecycle events (pause/resume cycles, idle time) for analytics.
    
Task events track pause/resume activity, idle duration before pausing,
paused duration, and time from resume to first app status, filtered to
recent activity based on the telemetry snapshot interval.

🤖 Created with Mux (Opus 4.6).
This commit is contained in:
Cian Johnston
2026-02-24 17:04:42 +00:00
committed by GitHub
parent 974ca3eda6
commit 6336fee3a7
11 changed files with 1158 additions and 26 deletions
+163 -25
View File
@@ -416,9 +416,10 @@ func checkIDPOrgSync(ctx context.Context, db database.Store, values *codersdk.De
func (r *remoteReporter) createSnapshot() (*Snapshot, error) {
var (
ctx = r.ctx
now = r.options.Clock.Now()
// For resources that grow in size very quickly (like workspace builds),
// we only report events that occurred within the past hour.
createdAfter = dbtime.Time(r.options.Clock.Now().Add(-1 * time.Hour)).UTC()
createdAfter = dbtime.Time(now.Add(-1 * time.Hour)).UTC()
eg errgroup.Group
snapshot = &Snapshot{
DeploymentID: r.options.DeploymentID,
@@ -740,17 +741,19 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) {
return nil
})
eg.Go(func() error {
dbTasks, err := r.options.Database.ListTasks(ctx, database.ListTasksParams{
OwnerID: uuid.Nil,
OrganizationID: uuid.Nil,
Status: "",
})
tasks, err := CollectTasks(ctx, r.options.Database)
if err != nil {
return err
return xerrors.Errorf("collect tasks telemetry: %w", err)
}
for _, dbTask := range dbTasks {
snapshot.Tasks = append(snapshot.Tasks, ConvertTask(dbTask))
snapshot.Tasks = tasks
return nil
})
eg.Go(func() error {
events, err := CollectTaskEvents(ctx, r.options.Database, createdAfter, now)
if err != nil {
return xerrors.Errorf("collect task events telemetry: %w", err)
}
snapshot.TaskEvents = events
return nil
})
eg.Go(func() error {
@@ -902,6 +905,129 @@ func (r *remoteReporter) collectBoundaryUsageSummary(ctx context.Context) (*Boun
}, nil
}
func CollectTasks(ctx context.Context, db database.Store) ([]Task, error) {
dbTasks, err := db.ListTasks(ctx, database.ListTasksParams{
OwnerID: uuid.Nil,
OrganizationID: uuid.Nil,
Status: "",
})
if err != nil {
return nil, xerrors.Errorf("list tasks: %w", err)
}
if len(dbTasks) == 0 {
return []Task{}, nil
}
tasks := make([]Task, 0, len(dbTasks))
for _, dbTask := range dbTasks {
tasks = append(tasks, ConvertTask(dbTask))
}
return tasks, nil
}
// buildTaskEvent constructs a TaskEvent from the combined query row.
func buildTaskEvent(
row database.GetTelemetryTaskEventsRow,
createdAfter time.Time,
now time.Time,
) TaskEvent {
event := TaskEvent{
TaskID: row.TaskID.String(),
}
var (
hasStartBuild = row.StartBuildCreatedAt.Valid
isResumed = hasStartBuild && row.StartBuildNumber.Valid && row.StartBuildNumber.Int32 > 1
hasStopBuild = row.StopBuildCreatedAt.Valid
startedAfterStop = hasStartBuild && hasStopBuild && row.StartBuildCreatedAt.Time.After(row.StopBuildCreatedAt.Time)
currentlyPaused = hasStopBuild && !startedAfterStop
)
// Pause-related fields (requires a stop build).
if hasStopBuild {
event.LastPausedAt = &row.StopBuildCreatedAt.Time
switch {
case row.StopBuildReason.Valid && row.StopBuildReason.BuildReason == database.BuildReasonTaskAutoPause:
event.PauseReason = ptr.Ref("auto")
case row.StopBuildReason.Valid && row.StopBuildReason.BuildReason == database.BuildReasonTaskManualPause:
event.PauseReason = ptr.Ref("manual")
default:
event.PauseReason = ptr.Ref("other")
}
// Idle duration: time between last working status and the pause.
if row.LastWorkingStatusAt.Valid &&
row.StopBuildCreatedAt.Time.After(row.LastWorkingStatusAt.Time) {
idle := row.StopBuildCreatedAt.Time.Sub(row.LastWorkingStatusAt.Time)
event.IdleDurationMS = ptr.Ref(idle.Milliseconds())
}
}
// Resume-related fields (requires task_resume start after stop).
if startedAfterStop {
// Paused duration: time between pause and resume.
if row.StartBuildCreatedAt.Time.After(createdAfter) {
paused := row.StartBuildCreatedAt.Time.Sub(row.StopBuildCreatedAt.Time)
event.PausedDurationMS = ptr.Ref(paused.Milliseconds())
}
// Below only relevant for "resumed" tasks, not when initially created.
if isResumed {
event.LastResumedAt = &row.StartBuildCreatedAt.Time
switch {
// TODO(Cian): will this exist? Future readers may know better than I.
// case row.StartBuildReason == database.BuildReasonTaskAutoResume:
// event.ResumeReason = ptr.Ref("auto")
case row.StartBuildReason.BuildReason == database.BuildReasonTaskResume:
event.ResumeReason = ptr.Ref("manual")
default: // Task resumed by starting workspace?
event.ResumeReason = ptr.Ref("other")
}
}
}
// Unresolved pause: report current paused duration.
if currentlyPaused {
paused := now.Sub(row.StopBuildCreatedAt.Time)
event.PausedDurationMS = ptr.Ref(paused.Milliseconds())
}
// Resume-to-status duration.
if row.FirstStatusAfterResumeAt.Valid && isResumed {
delta := row.FirstStatusAfterResumeAt.Time.Sub(row.StartBuildCreatedAt.Time)
event.ResumeToStatusMS = ptr.Ref(delta.Milliseconds())
}
// Active duration: from SQL calculation.
if row.ActiveDurationMs > 0 {
event.ActiveDurationMS = ptr.Ref(row.ActiveDurationMs)
}
return event
}
// CollectTaskEvents collects lifecycle events for tasks with recent activity.
func CollectTaskEvents(ctx context.Context, db database.Store, createdAfter, now time.Time) ([]TaskEvent, error) {
rows, err := db.GetTelemetryTaskEvents(ctx, database.GetTelemetryTaskEventsParams{
CreatedAfter: createdAfter,
Now: now,
})
if err != nil {
return nil, xerrors.Errorf("get telemetry task events: %w", err)
}
events := make([]TaskEvent, 0, len(rows))
for _, row := range rows {
events = append(events, buildTaskEvent(row, createdAfter, now))
}
return events, nil
}
// HashContent returns a SHA256 hash of the content as a hex string.
// This is useful for hashing sensitive content like prompts for telemetry.
func HashContent(content string) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(content)))
}
// ConvertAPIKey anonymizes an API key.
func ConvertAPIKey(apiKey database.APIKey) APIKey {
a := APIKey{
@@ -1370,6 +1496,7 @@ type Snapshot struct {
NetworkEvents []NetworkEvent `json:"network_events"`
Organizations []Organization `json:"organizations"`
Tasks []Task `json:"tasks"`
TaskEvents []TaskEvent `json:"task_events"`
TelemetryItems []TelemetryItem `json:"telemetry_items"`
UserTailnetConnections []UserTailnetConnection `json:"user_tailnet_connections"`
PrebuiltWorkspaces []PrebuiltWorkspace `json:"prebuilt_workspaces"`
@@ -1931,25 +2058,36 @@ type Task struct {
WorkspaceAppID *string `json:"workspace_app_id"`
TemplateVersionID string `json:"template_version_id"`
PromptHash string `json:"prompt_hash"` // Prompt is hashed for privacy.
CreatedAt time.Time `json:"created_at"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// ConvertTask anonymizes a Task.
// TaskEvent represents lifecycle events for a task (pause/resume
// cycles). The createdAfter parameter gates PausedDurationMS so
// that only recent pause/resume pairs are reported.
type TaskEvent struct {
TaskID string `json:"task_id"`
LastPausedAt *time.Time `json:"last_paused_at"`
LastResumedAt *time.Time `json:"last_resumed_at"`
PauseReason *string `json:"pause_reason"`
ResumeReason *string `json:"resume_reason"`
IdleDurationMS *int64 `json:"idle_duration_ms"`
PausedDurationMS *int64 `json:"paused_duration_ms"`
ResumeToStatusMS *int64 `json:"resume_to_status_ms"`
ActiveDurationMS *int64 `json:"active_duration_ms"`
}
// ConvertTask converts a database Task to a telemetry Task.
func ConvertTask(task database.Task) Task {
t := &Task{
ID: task.ID.String(),
OrganizationID: task.OrganizationID.String(),
OwnerID: task.OwnerID.String(),
Name: task.Name,
WorkspaceID: nil,
WorkspaceBuildNumber: nil,
WorkspaceAgentID: nil,
WorkspaceAppID: nil,
TemplateVersionID: task.TemplateVersionID.String(),
PromptHash: fmt.Sprintf("%x", sha256.Sum256([]byte(task.Prompt))),
CreatedAt: task.CreatedAt,
Status: string(task.Status),
t := Task{
ID: task.ID.String(),
OrganizationID: task.OrganizationID.String(),
OwnerID: task.OwnerID.String(),
Name: task.Name,
TemplateVersionID: task.TemplateVersionID.String(),
PromptHash: HashContent(task.Prompt),
Status: string(task.Status),
CreatedAt: task.CreatedAt,
}
if task.WorkspaceID.Valid {
t.WorkspaceID = ptr.Ref(task.WorkspaceID.UUID.String())
@@ -1963,7 +2101,7 @@ func ConvertTask(task database.Task) Task {
if task.WorkspaceAppID.Valid {
t.WorkspaceAppID = ptr.Ref(task.WorkspaceAppID.UUID.String())
}
return *t
return t
}
type telemetryItemKey string