Skip to content

Commit daacf28

Browse files
authored
feat: add prometheus metrics (#459)
1 parent 822ec35 commit daacf28

16 files changed

+135
-1
lines changed

cmd/backrest/backrest.go

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/garethgeorge/backrest/internal/config"
2222
"github.com/garethgeorge/backrest/internal/env"
2323
"github.com/garethgeorge/backrest/internal/logwriter"
24+
"github.com/garethgeorge/backrest/internal/metric"
2425
"github.com/garethgeorge/backrest/internal/oplog"
2526
"github.com/garethgeorge/backrest/internal/oplog/bboltstore"
2627
"github.com/garethgeorge/backrest/internal/orchestrator"
@@ -116,6 +117,7 @@ func main() {
116117
mux.Handle(backrestHandlerPath, auth.RequireAuthentication(backrestHandler, authenticator))
117118
mux.Handle("/", webui.Handler())
118119
mux.Handle("/download/", http.StripPrefix("/download", api.NewDownloadHandler(oplog)))
120+
mux.Handle("/metrics", auth.RequireAuthentication(metric.GetRegistry().Handler(), authenticator))
119121

120122
// Serve the HTTP gateway
121123
server := &http.Server{

go.mod

+8
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ require (
3232

3333
require (
3434
github.com/akavel/rsrc v0.10.2 // indirect
35+
github.com/beorn7/perks v1.0.1 // indirect
36+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
3537
github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect
3638
github.com/fatih/color v1.17.0 // indirect
3739
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
@@ -45,8 +47,14 @@ require (
4547
github.com/go-stack/stack v1.8.1 // indirect
4648
github.com/hashicorp/errwrap v1.1.0 // indirect
4749
github.com/josephspurrier/goversioninfo v1.4.0 // indirect
50+
github.com/klauspost/compress v1.17.9 // indirect
4851
github.com/mattn/go-isatty v0.0.20 // indirect
52+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
4953
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
54+
github.com/prometheus/client_golang v1.20.3 // indirect
55+
github.com/prometheus/client_model v0.6.1 // indirect
56+
github.com/prometheus/common v0.55.0 // indirect
57+
github.com/prometheus/procfs v0.15.1 // indirect
5058
github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect
5159
go.opentelemetry.io/otel v1.27.0 // indirect
5260
go.opentelemetry.io/otel/metric v1.27.0 // indirect

go.sum

+16
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxk
55
github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0=
66
github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
77
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
8+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
9+
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
10+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
11+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
812
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
913
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
1014
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -77,6 +81,8 @@ github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nu
7781
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
7882
github.com/josephspurrier/goversioninfo v1.4.0 h1:Puhl12NSHUSALHSuzYwPYQkqa2E1+7SrtAPJorKK0C8=
7983
github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY=
84+
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
85+
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
8086
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
8187
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
8288
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
@@ -87,6 +93,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
8793
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
8894
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
8995
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
96+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
97+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
9098
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
9199
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
92100
github.com/ncruces/zenity v0.10.12 h1:o4SErDa0kQijlqG6W4OYYzO6kA0fGu34uegvJGcMLBI=
@@ -100,6 +108,14 @@ github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwU
100108
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
101109
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
102110
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
111+
github.com/prometheus/client_golang v1.20.3 h1:oPksm4K8B+Vt35tUhw6GbSNSgVlVSBH0qELP/7u83l4=
112+
github.com/prometheus/client_golang v1.20.3/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
113+
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
114+
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
115+
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
116+
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
117+
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
118+
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
103119
github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc=
104120
github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8=
105121
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=

internal/hook/hook.go

+1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ func newOneoffRunHookTask(title, instanceID, repoID, planID string, parentOp *v1
6666
return &tasks.GenericOneoffTask{
6767
OneoffTask: tasks.OneoffTask{
6868
BaseTask: tasks.BaseTask{
69+
TaskType: "hook",
6970
TaskName: fmt.Sprintf("run hook %v", title),
7071
TaskRepoID: repoID,
7172
TaskPlanID: planID,

internal/metric/metric.go

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package metric
2+
3+
import (
4+
"net/http"
5+
"slices"
6+
7+
"github.com/prometheus/client_golang/prometheus"
8+
"github.com/prometheus/client_golang/prometheus/promhttp"
9+
)
10+
11+
var (
12+
globalRegistry = initRegistry()
13+
)
14+
15+
func initRegistry() *Registry {
16+
17+
commonDims := []string{"repo_id", "plan_id"}
18+
19+
registry := &Registry{
20+
reg: prometheus.NewRegistry(),
21+
backupBytesProcessed: prometheus.NewSummaryVec(prometheus.SummaryOpts{
22+
Name: "backrest_backup_bytes_processed",
23+
Help: "The total number of bytes processed during a backup",
24+
}, commonDims),
25+
backupBytesAdded: prometheus.NewSummaryVec(prometheus.SummaryOpts{
26+
Name: "backrest_backup_bytes_added",
27+
Help: "The total number of bytes added during a backup",
28+
}, commonDims),
29+
backupFileWarnings: prometheus.NewSummaryVec(prometheus.SummaryOpts{
30+
Name: "backrest_backup_file_warnings",
31+
Help: "The total number of file warnings during a backup",
32+
}, commonDims),
33+
tasksDuration: prometheus.NewSummaryVec(prometheus.SummaryOpts{
34+
Name: "backrest_tasks_duration_secs",
35+
Help: "The duration of a task in seconds",
36+
}, append(slices.Clone(commonDims), "task_type")),
37+
tasksRun: prometheus.NewCounterVec(prometheus.CounterOpts{
38+
Name: "backrest_tasks_run_total",
39+
Help: "The total number of tasks run",
40+
}, append(slices.Clone(commonDims), "task_type", "status")),
41+
}
42+
43+
registry.reg.MustRegister(registry.backupBytesProcessed)
44+
registry.reg.MustRegister(registry.backupBytesAdded)
45+
registry.reg.MustRegister(registry.backupFileWarnings)
46+
registry.reg.MustRegister(registry.tasksDuration)
47+
registry.reg.MustRegister(registry.tasksRun)
48+
49+
return registry
50+
}
51+
52+
func GetRegistry() *Registry {
53+
return globalRegistry
54+
}
55+
56+
type Registry struct {
57+
reg *prometheus.Registry
58+
backupBytesProcessed *prometheus.SummaryVec
59+
backupBytesAdded *prometheus.SummaryVec
60+
backupFileWarnings *prometheus.SummaryVec
61+
tasksDuration *prometheus.SummaryVec
62+
tasksRun *prometheus.CounterVec
63+
}
64+
65+
func (r *Registry) Handler() http.Handler {
66+
return promhttp.HandlerFor(r.reg, promhttp.HandlerOpts{})
67+
}
68+
69+
func (r *Registry) RecordTaskRun(repoID, planID, taskType string, duration_secs float64, status string) {
70+
if repoID == "" {
71+
repoID = "_unassociated_"
72+
}
73+
if planID == "" {
74+
planID = "_unassociated_"
75+
}
76+
r.tasksRun.WithLabelValues(repoID, planID, taskType, status).Inc()
77+
r.tasksDuration.WithLabelValues(repoID, planID, taskType).Observe(duration_secs)
78+
}
79+
80+
func (r *Registry) RecordBackupSummary(repoID, planID string, bytesProcessed, bytesAdded int64, fileWarnings int64) {
81+
r.backupBytesProcessed.WithLabelValues(repoID, planID).Observe(float64(bytesProcessed))
82+
r.backupBytesAdded.WithLabelValues(repoID, planID).Observe(float64(bytesAdded))
83+
r.backupFileWarnings.WithLabelValues(repoID, planID).Observe(float64(fileWarnings))
84+
}

internal/orchestrator/orchestrator.go

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
v1 "github.com/garethgeorge/backrest/gen/go/v1"
1313
"github.com/garethgeorge/backrest/internal/config"
1414
"github.com/garethgeorge/backrest/internal/logwriter"
15+
"github.com/garethgeorge/backrest/internal/metric"
1516
"github.com/garethgeorge/backrest/internal/oplog"
1617
"github.com/garethgeorge/backrest/internal/orchestrator/logging"
1718
"github.com/garethgeorge/backrest/internal/orchestrator/repo"
@@ -426,6 +427,7 @@ func (o *Orchestrator) RunTask(ctx context.Context, st tasks.ScheduledTask) erro
426427
runner.Logger(ctx).Error("task failed", zap.Error(err), zap.Duration("duration", time.Since(start)))
427428
} else {
428429
runner.Logger(ctx).Info("task finished", zap.Duration("duration", time.Since(start)))
430+
metric.GetRegistry().RecordTaskRun(st.Task.RepoID(), st.Task.PlanID(), st.Task.Type(), time.Since(start).Seconds(), "success")
429431
}
430432

431433
if op != nil {

internal/orchestrator/tasks/task.go

+7-1
Original file line numberDiff line numberDiff line change
@@ -86,18 +86,24 @@ func (s ScheduledTask) Less(other ScheduledTask) bool {
8686
// Task is a task that can be scheduled to run at a specific time.
8787
type Task interface {
8888
Name() string // human readable name for this task.
89+
Type() string // simple string 'type' for this task.
8990
Next(now time.Time, runner TaskRunner) (ScheduledTask, error) // returns the next scheduled task.
9091
Run(ctx context.Context, st ScheduledTask, runner TaskRunner) error // run the task.
9192
PlanID() string // the ID of the plan this task is associated with.
9293
RepoID() string // the ID of the repo this task is associated with.
9394
}
9495

9596
type BaseTask struct {
97+
TaskType string
9698
TaskName string
9799
TaskPlanID string
98100
TaskRepoID string
99101
}
100102

103+
func (b BaseTask) Type() string {
104+
return b.TaskType
105+
}
106+
101107
func (b BaseTask) Name() string {
102108
return b.TaskName
103109
}
@@ -164,7 +170,7 @@ type testTaskRunner struct {
164170

165171
var _ TaskRunner = &testTaskRunner{}
166172

167-
func newTestTaskRunner(t testing.TB, config *v1.Config, oplog *oplog.OpLog) *testTaskRunner {
173+
func newTestTaskRunner(_ testing.TB, config *v1.Config, oplog *oplog.OpLog) *testTaskRunner {
168174
return &testTaskRunner{
169175
config: config,
170176
oplog: oplog,

internal/orchestrator/tasks/taskbackup.go

+7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
v1 "github.com/garethgeorge/backrest/gen/go/v1"
12+
"github.com/garethgeorge/backrest/internal/metric"
1213
"github.com/garethgeorge/backrest/internal/oplog"
1314
"github.com/garethgeorge/backrest/internal/protoutil"
1415
"github.com/garethgeorge/backrest/pkg/restic"
@@ -29,6 +30,7 @@ var _ Task = &BackupTask{}
2930
func NewScheduledBackupTask(plan *v1.Plan) *BackupTask {
3031
return &BackupTask{
3132
BaseTask: BaseTask{
33+
TaskType: "backup",
3234
TaskName: fmt.Sprintf("backup for plan %q", plan.Id),
3335
TaskRepoID: plan.Repo,
3436
TaskPlanID: plan.Id,
@@ -39,6 +41,7 @@ func NewScheduledBackupTask(plan *v1.Plan) *BackupTask {
3941
func NewOneoffBackupTask(plan *v1.Plan, at time.Time) *BackupTask {
4042
return &BackupTask{
4143
BaseTask: BaseTask{
44+
TaskType: "backup",
4245
TaskName: fmt.Sprintf("backup for plan %q", plan.Id),
4346
TaskRepoID: plan.Repo,
4447
TaskPlanID: plan.Id,
@@ -132,6 +135,7 @@ func (t *BackupTask) Run(ctx context.Context, st ScheduledTask, runner TaskRunne
132135
var sendWg sync.WaitGroup
133136
lastSent := time.Now() // debounce progress updates, these can endup being very frequent.
134137
var lastFiles []string
138+
fileErrorCount := 0
135139
summary, err := repo.Backup(ctx, plan, func(entry *restic.BackupProgressEntry) {
136140
sendWg.Wait()
137141
if entry.MessageType == "status" {
@@ -145,6 +149,7 @@ func (t *BackupTask) Run(ctx context.Context, st ScheduledTask, runner TaskRunne
145149
backupOp.OperationBackup.LastStatus = protoutil.BackupProgressEntryToProto(entry)
146150
} else if entry.MessageType == "error" {
147151
l.Sugar().Warnf("an unknown error was encountered in processing item: %v", entry.Item)
152+
fileErrorCount++
148153
backupError, err := protoutil.BackupProgressEntryToBackupError(entry)
149154
if err != nil {
150155
l.Sugar().Errorf("failed to convert backup progress entry to backup error: %v", err)
@@ -180,6 +185,8 @@ func (t *BackupTask) Run(ctx context.Context, st ScheduledTask, runner TaskRunne
180185
summary = &restic.BackupProgressEntry{}
181186
}
182187

188+
metric.GetRegistry().RecordBackupSummary(t.RepoID(), t.PlanID(), summary.TotalBytesProcessed, summary.DataAdded, int64(fileErrorCount))
189+
183190
vars := HookVars{
184191
Task: t.Name(),
185192
SnapshotStats: summary,

internal/orchestrator/tasks/taskcheck.go

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type CheckTask struct {
2020
func NewCheckTask(repoID, planID string, force bool) Task {
2121
return &CheckTask{
2222
BaseTask: BaseTask{
23+
TaskType: "check",
2324
TaskName: fmt.Sprintf("check for repo %q", repoID),
2425
TaskRepoID: repoID,
2526
TaskPlanID: planID,

internal/orchestrator/tasks/taskcollectgarbage.go

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type CollectGarbageTask struct {
3535
func NewCollectGarbageTask() *CollectGarbageTask {
3636
return &CollectGarbageTask{
3737
BaseTask: BaseTask{
38+
TaskType: "collect_garbage",
3839
TaskName: "collect garbage",
3940
},
4041
}

internal/orchestrator/tasks/taskforget.go

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func NewOneoffForgetTask(repoID, planID string, flowID int64, at time.Time) Task
1616
return &GenericOneoffTask{
1717
OneoffTask: OneoffTask{
1818
BaseTask: BaseTask{
19+
TaskType: "forget",
1920
TaskName: fmt.Sprintf("forget for plan %q in repo %q", repoID, planID),
2021
TaskRepoID: repoID,
2122
TaskPlanID: planID,

internal/orchestrator/tasks/taskforgetsnapshot.go

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ func NewOneoffForgetSnapshotTask(repoID, planID string, flowID int64, at time.Ti
1212
return &GenericOneoffTask{
1313
OneoffTask: OneoffTask{
1414
BaseTask: BaseTask{
15+
TaskType: "forget_snapshot",
1516
TaskName: fmt.Sprintf("forget snapshot %q for plan %q in repo %q", snapshotID, planID, repoID),
1617
TaskRepoID: repoID,
1718
TaskPlanID: planID,

internal/orchestrator/tasks/taskindexsnapshots.go

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func NewOneoffIndexSnapshotsTask(repoID string, at time.Time) Task {
1919
return &GenericOneoffTask{
2020
OneoffTask: OneoffTask{
2121
BaseTask: BaseTask{
22+
TaskType: "index_snapshots",
2223
TaskName: fmt.Sprintf("index snapshots for repo %q", repoID),
2324
TaskRepoID: repoID,
2425
},

internal/orchestrator/tasks/taskprune.go

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type PruneTask struct {
2121
func NewPruneTask(repoID, planID string, force bool) Task {
2222
return &PruneTask{
2323
BaseTask: BaseTask{
24+
TaskType: "prune",
2425
TaskName: fmt.Sprintf("prune repo %q", repoID),
2526
TaskRepoID: repoID,
2627
TaskPlanID: planID,

internal/orchestrator/tasks/taskrestore.go

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ func NewOneoffRestoreTask(repoID, planID string, flowID int64, at time.Time, sna
1515
return &GenericOneoffTask{
1616
OneoffTask: OneoffTask{
1717
BaseTask: BaseTask{
18+
TaskType: "restore",
1819
TaskName: fmt.Sprintf("restore snapshot %q in repo %q", snapshotID, repoID),
1920
TaskRepoID: repoID,
2021
TaskPlanID: planID,

internal/orchestrator/tasks/taskstats.go

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type StatsTask struct {
1818
func NewStatsTask(repoID, planID string, force bool) Task {
1919
return &StatsTask{
2020
BaseTask: BaseTask{
21+
TaskType: "stats",
2122
TaskName: fmt.Sprintf("stats for repo %q", repoID),
2223
TaskRepoID: repoID,
2324
TaskPlanID: planID,

0 commit comments

Comments
 (0)