mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+163
-25
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user