detached 39593e0

feat(backup): export OTel metrics for database and volume backups

Record every backup run (result, duration, size) and expose the last success per target plus the next scheduled run as gauges. The last success is seeded from stored dumps and S3 archives at startup so it survives restarts. Docs list the metrics and example alerts.
commit
39593e0effdc96037af4725e5d8ae2d6763334b6
author
brice · · 2026-10-17 06:51 UTC
parent
108f11b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +747 -2
M docs/config/backups.md +5 -0
@@ -105,6 +105,10 @@ Objects written by earlier versions under a domain prefix are not read or
105105 migrated: back them up and re-run the app's volume backups if you need them in
106106 the current layout.
107107
108+## Monitoring
109+
110+With [telemetry](./telemetry.md) metrics enabled, Gordon exports the result, duration, and size of every backup run, the last success of each target, and the next scheduled run. See [Backups metrics](./telemetry.md#backups) for the metric names and example alerts.
111+
108112 ## Notes
109113
110114 - Volume backups archive the app's declared volumes that its backup declaration references.
@@ -122,3 +126,4 @@ the current layout.
122126 - [App Manifest](./apps.md)
123127 - [CLI Backup Command](../cli/backup.md)
124128 - [Configuration Reference](./reference.md)
129+- [Telemetry](./telemetry.md)
@@ -105,6 +105,10 @@ Objects written by earlier versions under a domain prefix are not read or
105 migrated: back them up and re-run the app's volume backups if you need them in105 migrated: back them up and re-run the app's volume backups if you need them in
106 the current layout.106 the current layout.
107 107
108+## Monitoring
109+
110+With [telemetry](./telemetry.md) metrics enabled, Gordon exports the result, duration, and size of every backup run, the last success of each target, and the next scheduled run. See [Backups metrics](./telemetry.md#backups) for the metric names and example alerts.
111+
108 ## Notes112 ## Notes
109 113
110 - Volume backups archive the app's declared volumes that its backup declaration references.114 - Volume backups archive the app's declared volumes that its backup declaration references.
@@ -122,3 +126,4 @@ the current layout.
122 - [App Manifest](./apps.md)126 - [App Manifest](./apps.md)
123 - [CLI Backup Command](../cli/backup.md)127 - [CLI Backup Command](../cli/backup.md)
124 - [Configuration Reference](./reference.md)128 - [Configuration Reference](./reference.md)
129+- [Telemetry](./telemetry.md)
M docs/config/telemetry.md +25 -1
@@ -25,7 +25,7 @@ trace_sample_rate = 1.0
2525 | `endpoint` | string | `""` | OTLP HTTP endpoint URL (e.g. `http://localhost:4318/api/default`) |
2626 | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth |
2727 | `traces` | bool | `true` | Export distributed traces |
28-| `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events) |
28+| `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events, backups) |
2929 | `logs` | bool | `true` | Export Gordon, proxy access, and app container logs to OTLP |
3030 | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) |
3131
@@ -160,6 +160,30 @@ Attributes: `source` (restarts only: `monitor` or `api`). `gordon.container.mana
160160
161161 Attributes: `name`, `reference`
162162
163+### Backups
164+
165+| Metric | Type | Unit | Description |
166+|--------|------|------|-------------|
167+| `gordon.backup.runs` | Counter | - | Finished backup runs (scheduled and manual) |
168+| `gordon.backup.duration_seconds` | Histogram | s | Duration of each backup run |
169+| `gordon.backup.last_success_timestamp_seconds` | Gauge | s | Unix time of the last successful backup per target |
170+| `gordon.backup.last_size_bytes` | Gauge | By | Size of the last successful backup per target |
171+| `gordon.backup.next_run_timestamp_seconds` | Gauge | s | Unix time of the next scheduled run |
172+
173+Attributes: `kind` (`database` or `volume`), `app`, `service`, `target` (database name or declared volume name), and `result` (`success` or `failure`, runs only). `next_run_timestamp_seconds` only carries `kind`.
174+
175+At startup Gordon reads the stored dumps and S3 archives to restore each target's last success, so the gauges stay correct across restarts. A run missed while Gordon is down never appears as a failure: alert on the age of the last success instead.
176+
177+With the OpenTelemetry Collector Prometheus exporter, names become `gordon_backup_runs_total`, `gordon_backup_last_success_timestamp_seconds`, and so on. Example alerts:
178+
179+```promql
180+# No successful backup of a target for more than 26 hours
181+time() - gordon_backup_last_success_timestamp_seconds > 26 * 3600
182+
183+# Gordon stopped exporting backup metrics
184+absent_over_time(gordon_backup_next_run_timestamp_seconds[15m])
185+```
186+
163187 ### Event Bus
164188
165189 | Metric | Type | Unit | Description |
@@ -25,7 +25,7 @@ trace_sample_rate = 1.0
25 | `endpoint` | string | `""` | OTLP HTTP endpoint URL (e.g. `http://localhost:4318/api/default`) |25 | `endpoint` | string | `""` | OTLP HTTP endpoint URL (e.g. `http://localhost:4318/api/default`) |
26 | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth |26 | `auth_token` | string | `""` | Base64-encoded `user:password` for Basic auth |
27 | `traces` | bool | `true` | Export distributed traces |27 | `traces` | bool | `true` | Export distributed traces |
28-| `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events) |28+| `metrics` | bool | `true` | Export metrics (deploy counters, container lifecycle, registry, events, backups) |
29 | `logs` | bool | `true` | Export Gordon, proxy access, and app container logs to OTLP |29 | `logs` | bool | `true` | Export Gordon, proxy access, and app container logs to OTLP |
30 | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) |30 | `trace_sample_rate` | float | `1.0` | Fraction of traces to sample (`0.0` = none, `1.0` = all) |
31 31
@@ -160,6 +160,30 @@ Attributes: `source` (restarts only: `monitor` or `api`). `gordon.container.mana
160 160
161 Attributes: `name`, `reference`161 Attributes: `name`, `reference`
162 162
163+### Backups
164+
165+| Metric | Type | Unit | Description |
166+|--------|------|------|-------------|
167+| `gordon.backup.runs` | Counter | - | Finished backup runs (scheduled and manual) |
168+| `gordon.backup.duration_seconds` | Histogram | s | Duration of each backup run |
169+| `gordon.backup.last_success_timestamp_seconds` | Gauge | s | Unix time of the last successful backup per target |
170+| `gordon.backup.last_size_bytes` | Gauge | By | Size of the last successful backup per target |
171+| `gordon.backup.next_run_timestamp_seconds` | Gauge | s | Unix time of the next scheduled run |
172+
173+Attributes: `kind` (`database` or `volume`), `app`, `service`, `target` (database name or declared volume name), and `result` (`success` or `failure`, runs only). `next_run_timestamp_seconds` only carries `kind`.
174+
175+At startup Gordon reads the stored dumps and S3 archives to restore each target's last success, so the gauges stay correct across restarts. A run missed while Gordon is down never appears as a failure: alert on the age of the last success instead.
176+
177+With the OpenTelemetry Collector Prometheus exporter, names become `gordon_backup_runs_total`, `gordon_backup_last_success_timestamp_seconds`, and so on. Example alerts:
178+
179+```promql
180+# No successful backup of a target for more than 26 hours
181+time() - gordon_backup_last_success_timestamp_seconds > 26 * 3600
182+
183+# Gordon stopped exporting backup metrics
184+absent_over_time(gordon_backup_next_run_timestamp_seconds[15m])
185+```
186+
163 ### Event Bus187 ### Event Bus
164 188
165 | Metric | Type | Unit | Description |189 | Metric | Type | Unit | Description |
A internal/adapters/out/telemetry/backup_metrics.go +145 -0
@@ -0,0 +1,145 @@
1+package telemetry
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "go.opentelemetry.io/otel/attribute"
9+ "go.opentelemetry.io/otel/metric"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+var _ out.BackupMetrics = (*Metrics)(nil)
16+
17+// backupTargetKey identifies one backed-up resource.
18+type backupTargetKey struct {
19+ kind domain.BackupKind
20+ app string
21+ service string
22+ target string
23+}
24+
25+func (k backupTargetKey) attrs() []attribute.KeyValue {
26+ return []attribute.KeyValue{
27+ attribute.String("kind", string(k.kind)),
28+ attribute.String("app", k.app),
29+ attribute.String("service", k.service),
30+ attribute.String("target", k.target),
31+ }
32+}
33+
34+type backupLastSuccess struct {
35+ at time.Time
36+ size int64
37+}
38+
39+// backupState holds the last success per target, exported by observable
40+// gauges at each collection.
41+type backupState struct {
42+ mu sync.Mutex
43+ last map[backupTargetKey]backupLastSuccess
44+ nextRun map[domain.BackupKind]func() time.Time
45+}
46+
47+func newBackupState() *backupState {
48+ return &backupState{
49+ last: map[backupTargetKey]backupLastSuccess{},
50+ nextRun: map[domain.BackupKind]func() time.Time{},
51+ }
52+}
53+
54+func keyOf(run domain.BackupRunRecord) backupTargetKey {
55+ return backupTargetKey{kind: run.Kind, app: run.App, service: run.Service, target: run.Target}
56+}
57+
58+// remember stores a success unless a newer one is already known.
59+func (s *backupState) remember(run domain.BackupRunRecord) {
60+ if !run.Succeeded || run.FinishedAt.IsZero() {
61+ return
62+ }
63+ key := keyOf(run)
64+ s.mu.Lock()
65+ defer s.mu.Unlock()
66+ if prev, ok := s.last[key]; ok && !run.FinishedAt.After(prev.at) {
67+ return
68+ }
69+ s.last[key] = backupLastSuccess{at: run.FinishedAt, size: run.SizeBytes}
70+}
71+
72+// registerBackupInstruments creates the backup counters, histogram, and
73+// observable gauges.
74+func (m *Metrics) registerBackupInstruments() error {
75+ var err error
76+ if m.BackupRuns, err = m.meter.Int64Counter("gordon.backup.runs",
77+ metric.WithDescription("Finished backup runs by result")); err != nil {
78+ return err
79+ }
80+ if m.BackupDuration, err = m.meter.Float64Histogram("gordon.backup.duration_seconds",
81+ metric.WithDescription("Backup run duration in seconds"),
82+ metric.WithUnit("s"),
83+ metric.WithExplicitBucketBoundaries(1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600)); err != nil {
84+ return err
85+ }
86+ lastSuccess, err := m.meter.Float64ObservableGauge("gordon.backup.last_success_timestamp_seconds",
87+ metric.WithDescription("Unix time of the last successful backup per target"),
88+ metric.WithUnit("s"))
89+ if err != nil {
90+ return err
91+ }
92+ lastSize, err := m.meter.Float64ObservableGauge("gordon.backup.last_size_bytes",
93+ metric.WithDescription("Size of the last successful backup per target"),
94+ metric.WithUnit("By"))
95+ if err != nil {
96+ return err
97+ }
98+ nextRun, err := m.meter.Float64ObservableGauge("gordon.backup.next_run_timestamp_seconds",
99+ metric.WithDescription("Unix time of the next scheduled backup run"),
100+ metric.WithUnit("s"))
101+ if err != nil {
102+ return err
103+ }
104+ _, err = m.meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
105+ m.backups.mu.Lock()
106+ defer m.backups.mu.Unlock()
107+ for key, last := range m.backups.last {
108+ attrs := metric.WithAttributes(key.attrs()...)
109+ o.ObserveFloat64(lastSuccess, float64(last.at.Unix()), attrs)
110+ o.ObserveFloat64(lastSize, float64(last.size), attrs)
111+ }
112+ for kind, next := range m.backups.nextRun {
113+ if at := next(); !at.IsZero() {
114+ o.ObserveFloat64(nextRun, float64(at.Unix()), metric.WithAttributes(attribute.String("kind", string(kind))))
115+ }
116+ }
117+ return nil
118+ }, lastSuccess, lastSize, nextRun)
119+ return err
120+}
121+
122+// RecordBackupRun implements out.BackupMetrics.
123+func (m *Metrics) RecordBackupRun(ctx context.Context, run domain.BackupRunRecord) {
124+ result := "failure"
125+ if run.Succeeded {
126+ result = "success"
127+ }
128+ attrs := keyOf(run).attrs()
129+ m.BackupRuns.Add(ctx, 1, metric.WithAttributes(append(attrs, attribute.String("result", result))...))
130+ m.BackupDuration.Record(ctx, run.Duration.Seconds(), metric.WithAttributes(attrs...))
131+ m.backups.remember(run)
132+}
133+
134+// ObserveStoredBackup implements out.BackupMetrics.
135+func (m *Metrics) ObserveStoredBackup(run domain.BackupRunRecord) {
136+ m.backups.remember(run)
137+}
138+
139+// ObserveBackupNextRun exports the next planned run of one backup kind.
140+// next is called at each collection; a zero time is not exported.
141+func (m *Metrics) ObserveBackupNextRun(kind domain.BackupKind, next func() time.Time) {
142+ m.backups.mu.Lock()
143+ defer m.backups.mu.Unlock()
144+ m.backups.nextRun[kind] = next
145+}
@@ -0,0 +1,145 @@
1+package telemetry
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "go.opentelemetry.io/otel/attribute"
9+ "go.opentelemetry.io/otel/metric"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+var _ out.BackupMetrics = (*Metrics)(nil)
16+
17+// backupTargetKey identifies one backed-up resource.
18+type backupTargetKey struct {
19+ kind domain.BackupKind
20+ app string
21+ service string
22+ target string
23+}
24+
25+func (k backupTargetKey) attrs() []attribute.KeyValue {
26+ return []attribute.KeyValue{
27+ attribute.String("kind", string(k.kind)),
28+ attribute.String("app", k.app),
29+ attribute.String("service", k.service),
30+ attribute.String("target", k.target),
31+ }
32+}
33+
34+type backupLastSuccess struct {
35+ at time.Time
36+ size int64
37+}
38+
39+// backupState holds the last success per target, exported by observable
40+// gauges at each collection.
41+type backupState struct {
42+ mu sync.Mutex
43+ last map[backupTargetKey]backupLastSuccess
44+ nextRun map[domain.BackupKind]func() time.Time
45+}
46+
47+func newBackupState() *backupState {
48+ return &backupState{
49+ last: map[backupTargetKey]backupLastSuccess{},
50+ nextRun: map[domain.BackupKind]func() time.Time{},
51+ }
52+}
53+
54+func keyOf(run domain.BackupRunRecord) backupTargetKey {
55+ return backupTargetKey{kind: run.Kind, app: run.App, service: run.Service, target: run.Target}
56+}
57+
58+// remember stores a success unless a newer one is already known.
59+func (s *backupState) remember(run domain.BackupRunRecord) {
60+ if !run.Succeeded || run.FinishedAt.IsZero() {
61+ return
62+ }
63+ key := keyOf(run)
64+ s.mu.Lock()
65+ defer s.mu.Unlock()
66+ if prev, ok := s.last[key]; ok && !run.FinishedAt.After(prev.at) {
67+ return
68+ }
69+ s.last[key] = backupLastSuccess{at: run.FinishedAt, size: run.SizeBytes}
70+}
71+
72+// registerBackupInstruments creates the backup counters, histogram, and
73+// observable gauges.
74+func (m *Metrics) registerBackupInstruments() error {
75+ var err error
76+ if m.BackupRuns, err = m.meter.Int64Counter("gordon.backup.runs",
77+ metric.WithDescription("Finished backup runs by result")); err != nil {
78+ return err
79+ }
80+ if m.BackupDuration, err = m.meter.Float64Histogram("gordon.backup.duration_seconds",
81+ metric.WithDescription("Backup run duration in seconds"),
82+ metric.WithUnit("s"),
83+ metric.WithExplicitBucketBoundaries(1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600)); err != nil {
84+ return err
85+ }
86+ lastSuccess, err := m.meter.Float64ObservableGauge("gordon.backup.last_success_timestamp_seconds",
87+ metric.WithDescription("Unix time of the last successful backup per target"),
88+ metric.WithUnit("s"))
89+ if err != nil {
90+ return err
91+ }
92+ lastSize, err := m.meter.Float64ObservableGauge("gordon.backup.last_size_bytes",
93+ metric.WithDescription("Size of the last successful backup per target"),
94+ metric.WithUnit("By"))
95+ if err != nil {
96+ return err
97+ }
98+ nextRun, err := m.meter.Float64ObservableGauge("gordon.backup.next_run_timestamp_seconds",
99+ metric.WithDescription("Unix time of the next scheduled backup run"),
100+ metric.WithUnit("s"))
101+ if err != nil {
102+ return err
103+ }
104+ _, err = m.meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
105+ m.backups.mu.Lock()
106+ defer m.backups.mu.Unlock()
107+ for key, last := range m.backups.last {
108+ attrs := metric.WithAttributes(key.attrs()...)
109+ o.ObserveFloat64(lastSuccess, float64(last.at.Unix()), attrs)
110+ o.ObserveFloat64(lastSize, float64(last.size), attrs)
111+ }
112+ for kind, next := range m.backups.nextRun {
113+ if at := next(); !at.IsZero() {
114+ o.ObserveFloat64(nextRun, float64(at.Unix()), metric.WithAttributes(attribute.String("kind", string(kind))))
115+ }
116+ }
117+ return nil
118+ }, lastSuccess, lastSize, nextRun)
119+ return err
120+}
121+
122+// RecordBackupRun implements out.BackupMetrics.
123+func (m *Metrics) RecordBackupRun(ctx context.Context, run domain.BackupRunRecord) {
124+ result := "failure"
125+ if run.Succeeded {
126+ result = "success"
127+ }
128+ attrs := keyOf(run).attrs()
129+ m.BackupRuns.Add(ctx, 1, metric.WithAttributes(append(attrs, attribute.String("result", result))...))
130+ m.BackupDuration.Record(ctx, run.Duration.Seconds(), metric.WithAttributes(attrs...))
131+ m.backups.remember(run)
132+}
133+
134+// ObserveStoredBackup implements out.BackupMetrics.
135+func (m *Metrics) ObserveStoredBackup(run domain.BackupRunRecord) {
136+ m.backups.remember(run)
137+}
138+
139+// ObserveBackupNextRun exports the next planned run of one backup kind.
140+// next is called at each collection; a zero time is not exported.
141+func (m *Metrics) ObserveBackupNextRun(kind domain.BackupKind, next func() time.Time) {
142+ m.backups.mu.Lock()
143+ defer m.backups.mu.Unlock()
144+ m.backups.nextRun[kind] = next
145+}
A internal/adapters/out/telemetry/backup_metrics_test.go +135 -0
@@ -0,0 +1,135 @@
1+package telemetry
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+ "go.opentelemetry.io/otel"
11+ "go.opentelemetry.io/otel/attribute"
12+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
13+ "go.opentelemetry.io/otel/sdk/metric/metricdata"
14+
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func newTestMetrics(t *testing.T) (*Metrics, *sdkmetric.ManualReader) {
19+ t.Helper()
20+ reader := sdkmetric.NewManualReader()
21+ prev := otel.GetMeterProvider()
22+ otel.SetMeterProvider(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)))
23+ t.Cleanup(func() { otel.SetMeterProvider(prev) })
24+ m, err := NewMetrics()
25+ require.NoError(t, err)
26+ return m, reader
27+}
28+
29+func collect(t *testing.T, reader *sdkmetric.ManualReader) map[string]metricdata.Aggregation {
30+ t.Helper()
31+ var rm metricdata.ResourceMetrics
32+ require.NoError(t, reader.Collect(context.Background(), &rm))
33+ out := map[string]metricdata.Aggregation{}
34+ for _, sm := range rm.ScopeMetrics {
35+ for _, m := range sm.Metrics {
36+ out[m.Name] = m.Data
37+ }
38+ }
39+ return out
40+}
41+
42+func attrValue(set attribute.Set, key string) string {
43+ v, _ := set.Value(attribute.Key(key))
44+ return v.AsString()
45+}
46+
47+// gaugeByTarget indexes float gauge points by their target attribute.
48+func gaugeByTarget(t *testing.T, data metricdata.Aggregation) map[string]float64 {
49+ t.Helper()
50+ gauge, ok := data.(metricdata.Gauge[float64])
51+ require.True(t, ok, "expected a float64 gauge, got %T", data)
52+ out := map[string]float64{}
53+ for _, dp := range gauge.DataPoints {
54+ out[attrValue(dp.Attributes, "target")] = dp.Value
55+ }
56+ return out
57+}
58+
59+func TestRecordBackupRun_CountsResultAndTracksLastSuccess(t *testing.T) {
60+ m, reader := newTestMetrics(t)
61+ ctx := context.Background()
62+ done := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)
63+
64+ m.RecordBackupRun(ctx, domain.BackupRunRecord{
65+ Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data",
66+ Succeeded: true, Duration: 40 * time.Second, SizeBytes: 1024, FinishedAt: done,
67+ })
68+ m.RecordBackupRun(ctx, domain.BackupRunRecord{
69+ Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data",
70+ Succeeded: false, Duration: time.Second, FinishedAt: done.Add(time.Hour),
71+ })
72+
73+ data := collect(t, reader)
74+
75+ runs, ok := data["gordon.backup.runs"].(metricdata.Sum[int64])
76+ require.True(t, ok)
77+ results := map[string]int64{}
78+ for _, dp := range runs.DataPoints {
79+ assert.Equal(t, "volume", attrValue(dp.Attributes, "kind"))
80+ assert.Equal(t, "forgejo", attrValue(dp.Attributes, "app"))
81+ assert.Equal(t, "forgejo", attrValue(dp.Attributes, "service"))
82+ assert.Equal(t, "forgejo-data", attrValue(dp.Attributes, "target"))
83+ results[attrValue(dp.Attributes, "result")] = dp.Value
84+ }
85+ assert.Equal(t, map[string]int64{"success": 1, "failure": 1}, results)
86+
87+ hist, ok := data["gordon.backup.duration_seconds"].(metricdata.Histogram[float64])
88+ require.True(t, ok)
89+ var count uint64
90+ for _, dp := range hist.DataPoints {
91+ count += dp.Count
92+ }
93+ assert.Equal(t, uint64(2), count)
94+
95+ // A failure never moves the last-success timestamp or size.
96+ assert.Equal(t, map[string]float64{"forgejo-data": float64(done.Unix())},
97+ gaugeByTarget(t, data["gordon.backup.last_success_timestamp_seconds"]))
98+ assert.Equal(t, map[string]float64{"forgejo-data": 1024},
99+ gaugeByTarget(t, data["gordon.backup.last_size_bytes"]))
100+}
101+
102+func TestObserveStoredBackup_KeepsTheNewestSuccess(t *testing.T) {
103+ m, reader := newTestMetrics(t)
104+ newer := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
105+ older := newer.Add(-24 * time.Hour)
106+ rec := domain.BackupRunRecord{Kind: domain.BackupKindDatabase, App: "shop", Service: "db", Target: "orders", Succeeded: true}
107+
108+ rec.FinishedAt, rec.SizeBytes = newer, 200
109+ m.ObserveStoredBackup(rec)
110+ rec.FinishedAt, rec.SizeBytes = older, 100
111+ m.ObserveStoredBackup(rec)
112+
113+ data := collect(t, reader)
114+ assert.Equal(t, map[string]float64{"orders": float64(newer.Unix())},
115+ gaugeByTarget(t, data["gordon.backup.last_success_timestamp_seconds"]))
116+ assert.Equal(t, map[string]float64{"orders": 200},
117+ gaugeByTarget(t, data["gordon.backup.last_size_bytes"]))
118+
119+ // Seeding never counts a run.
120+ _, counted := data["gordon.backup.runs"]
121+ assert.False(t, counted)
122+}
123+
124+func TestObserveBackupNextRun(t *testing.T) {
125+ m, reader := newTestMetrics(t)
126+ next := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)
127+ m.ObserveBackupNextRun(domain.BackupKindVolume, func() time.Time { return next })
128+ m.ObserveBackupNextRun(domain.BackupKindDatabase, func() time.Time { return time.Time{} })
129+
130+ gauge, ok := collect(t, reader)["gordon.backup.next_run_timestamp_seconds"].(metricdata.Gauge[float64])
131+ require.True(t, ok)
132+ require.Len(t, gauge.DataPoints, 1, "an unknown next run is not exported")
133+ assert.Equal(t, "volume", attrValue(gauge.DataPoints[0].Attributes, "kind"))
134+ assert.Equal(t, float64(next.Unix()), gauge.DataPoints[0].Value)
135+}
@@ -0,0 +1,135 @@
1+package telemetry
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+ "go.opentelemetry.io/otel"
11+ "go.opentelemetry.io/otel/attribute"
12+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
13+ "go.opentelemetry.io/otel/sdk/metric/metricdata"
14+
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func newTestMetrics(t *testing.T) (*Metrics, *sdkmetric.ManualReader) {
19+ t.Helper()
20+ reader := sdkmetric.NewManualReader()
21+ prev := otel.GetMeterProvider()
22+ otel.SetMeterProvider(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)))
23+ t.Cleanup(func() { otel.SetMeterProvider(prev) })
24+ m, err := NewMetrics()
25+ require.NoError(t, err)
26+ return m, reader
27+}
28+
29+func collect(t *testing.T, reader *sdkmetric.ManualReader) map[string]metricdata.Aggregation {
30+ t.Helper()
31+ var rm metricdata.ResourceMetrics
32+ require.NoError(t, reader.Collect(context.Background(), &rm))
33+ out := map[string]metricdata.Aggregation{}
34+ for _, sm := range rm.ScopeMetrics {
35+ for _, m := range sm.Metrics {
36+ out[m.Name] = m.Data
37+ }
38+ }
39+ return out
40+}
41+
42+func attrValue(set attribute.Set, key string) string {
43+ v, _ := set.Value(attribute.Key(key))
44+ return v.AsString()
45+}
46+
47+// gaugeByTarget indexes float gauge points by their target attribute.
48+func gaugeByTarget(t *testing.T, data metricdata.Aggregation) map[string]float64 {
49+ t.Helper()
50+ gauge, ok := data.(metricdata.Gauge[float64])
51+ require.True(t, ok, "expected a float64 gauge, got %T", data)
52+ out := map[string]float64{}
53+ for _, dp := range gauge.DataPoints {
54+ out[attrValue(dp.Attributes, "target")] = dp.Value
55+ }
56+ return out
57+}
58+
59+func TestRecordBackupRun_CountsResultAndTracksLastSuccess(t *testing.T) {
60+ m, reader := newTestMetrics(t)
61+ ctx := context.Background()
62+ done := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)
63+
64+ m.RecordBackupRun(ctx, domain.BackupRunRecord{
65+ Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data",
66+ Succeeded: true, Duration: 40 * time.Second, SizeBytes: 1024, FinishedAt: done,
67+ })
68+ m.RecordBackupRun(ctx, domain.BackupRunRecord{
69+ Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data",
70+ Succeeded: false, Duration: time.Second, FinishedAt: done.Add(time.Hour),
71+ })
72+
73+ data := collect(t, reader)
74+
75+ runs, ok := data["gordon.backup.runs"].(metricdata.Sum[int64])
76+ require.True(t, ok)
77+ results := map[string]int64{}
78+ for _, dp := range runs.DataPoints {
79+ assert.Equal(t, "volume", attrValue(dp.Attributes, "kind"))
80+ assert.Equal(t, "forgejo", attrValue(dp.Attributes, "app"))
81+ assert.Equal(t, "forgejo", attrValue(dp.Attributes, "service"))
82+ assert.Equal(t, "forgejo-data", attrValue(dp.Attributes, "target"))
83+ results[attrValue(dp.Attributes, "result")] = dp.Value
84+ }
85+ assert.Equal(t, map[string]int64{"success": 1, "failure": 1}, results)
86+
87+ hist, ok := data["gordon.backup.duration_seconds"].(metricdata.Histogram[float64])
88+ require.True(t, ok)
89+ var count uint64
90+ for _, dp := range hist.DataPoints {
91+ count += dp.Count
92+ }
93+ assert.Equal(t, uint64(2), count)
94+
95+ // A failure never moves the last-success timestamp or size.
96+ assert.Equal(t, map[string]float64{"forgejo-data": float64(done.Unix())},
97+ gaugeByTarget(t, data["gordon.backup.last_success_timestamp_seconds"]))
98+ assert.Equal(t, map[string]float64{"forgejo-data": 1024},
99+ gaugeByTarget(t, data["gordon.backup.last_size_bytes"]))
100+}
101+
102+func TestObserveStoredBackup_KeepsTheNewestSuccess(t *testing.T) {
103+ m, reader := newTestMetrics(t)
104+ newer := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
105+ older := newer.Add(-24 * time.Hour)
106+ rec := domain.BackupRunRecord{Kind: domain.BackupKindDatabase, App: "shop", Service: "db", Target: "orders", Succeeded: true}
107+
108+ rec.FinishedAt, rec.SizeBytes = newer, 200
109+ m.ObserveStoredBackup(rec)
110+ rec.FinishedAt, rec.SizeBytes = older, 100
111+ m.ObserveStoredBackup(rec)
112+
113+ data := collect(t, reader)
114+ assert.Equal(t, map[string]float64{"orders": float64(newer.Unix())},
115+ gaugeByTarget(t, data["gordon.backup.last_success_timestamp_seconds"]))
116+ assert.Equal(t, map[string]float64{"orders": 200},
117+ gaugeByTarget(t, data["gordon.backup.last_size_bytes"]))
118+
119+ // Seeding never counts a run.
120+ _, counted := data["gordon.backup.runs"]
121+ assert.False(t, counted)
122+}
123+
124+func TestObserveBackupNextRun(t *testing.T) {
125+ m, reader := newTestMetrics(t)
126+ next := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)
127+ m.ObserveBackupNextRun(domain.BackupKindVolume, func() time.Time { return next })
128+ m.ObserveBackupNextRun(domain.BackupKindDatabase, func() time.Time { return time.Time{} })
129+
130+ gauge, ok := collect(t, reader)["gordon.backup.next_run_timestamp_seconds"].(metricdata.Gauge[float64])
131+ require.True(t, ok)
132+ require.Len(t, gauge.DataPoints, 1, "an unknown next run is not exported")
133+ assert.Equal(t, "volume", attrValue(gauge.DataPoints[0].Attributes, "kind"))
134+ assert.Equal(t, float64(next.Unix()), gauge.DataPoints[0].Value)
135+}
M internal/adapters/out/telemetry/metrics.go +9 -1
@@ -33,6 +33,11 @@ type Metrics struct {
3333 // Events
3434 EventsProcessed metric.Int64Counter
3535 EventsDropped metric.Int64Counter
36+
37+ // Backups
38+ BackupRuns metric.Int64Counter
39+ BackupDuration metric.Float64Histogram
40+ backups *backupState
3641 }
3742
3843 // NewMetrics creates and registers all Gordon metric instruments.
@@ -40,7 +45,7 @@ type Metrics struct {
4045 // (OTel returns noop instruments when no MeterProvider is set).
4146 func NewMetrics() (*Metrics, error) {
4247 meter := otel.Meter("gordon")
43- m := &Metrics{meter: meter}
48+ m := &Metrics{meter: meter, backups: newBackupState()}
4449 var err error
4550
4651 if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total",
@@ -82,6 +87,9 @@ func NewMetrics() (*Metrics, error) {
8287 metric.WithDescription("Total events dropped")); err != nil {
8388 return nil, err
8489 }
90+ if err := m.registerBackupInstruments(); err != nil {
91+ return nil, err
92+ }
8593
8694 return m, nil
8795 }
@@ -33,6 +33,11 @@ type Metrics struct {
33 // Events33 // Events
34 EventsProcessed metric.Int64Counter34 EventsProcessed metric.Int64Counter
35 EventsDropped metric.Int64Counter35 EventsDropped metric.Int64Counter
36+
37+ // Backups
38+ BackupRuns metric.Int64Counter
39+ BackupDuration metric.Float64Histogram
40+ backups *backupState
36 }41 }
37 42
38 // NewMetrics creates and registers all Gordon metric instruments.43 // NewMetrics creates and registers all Gordon metric instruments.
@@ -40,7 +45,7 @@ type Metrics struct {
40 // (OTel returns noop instruments when no MeterProvider is set).45 // (OTel returns noop instruments when no MeterProvider is set).
41 func NewMetrics() (*Metrics, error) {46 func NewMetrics() (*Metrics, error) {
42 meter := otel.Meter("gordon")47 meter := otel.Meter("gordon")
43- m := &Metrics{meter: meter}48+ m := &Metrics{meter: meter, backups: newBackupState()}
44 var err error49 var err error
45 50
46 if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total",51 if m.DeployTotal, err = meter.Int64Counter("gordon.deploy.total",
@@ -82,6 +87,9 @@ func NewMetrics() (*Metrics, error) {
82 metric.WithDescription("Total events dropped")); err != nil {87 metric.WithDescription("Total events dropped")); err != nil {
83 return nil, err88 return nil, err
84 }89 }
90+ if err := m.registerBackupInstruments(); err != nil {
91+ return nil, err
92+ }
85 93
86 return m, nil94 return m, nil
87 }95 }
A internal/app/backup_metrics_wiring.go +71 -0
@@ -0,0 +1,71 @@
1+package app
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/zerowrap"
8+
9+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
10+ "github.com/bnema/gordon/internal/domain"
11+ cronSvc "github.com/bnema/gordon/internal/usecase/cron"
12+)
13+
14+// nextRunObserver exports the next planned run of one backup kind.
15+type nextRunObserver interface {
16+ ObserveBackupNextRun(kind domain.BackupKind, next func() time.Time)
17+}
18+
19+// nextRunObserverPort converts a possibly nil metrics adapter into an
20+// interface that is nil when metrics are disabled. A typed nil must not
21+// leak into the interface.
22+func nextRunObserverPort(metrics *telemetry.Metrics) nextRunObserver {
23+ if metrics == nil {
24+ return nil
25+ }
26+ return metrics
27+}
28+
29+// wireBackupMetrics injects backup metrics into the backup services. It
30+// must run before any backup can start, so no run reads the field while
31+// it is written.
32+func wireBackupMetrics(svc *services) {
33+ if svc == nil || svc.metrics == nil {
34+ return
35+ }
36+ if svc.backupSvc != nil {
37+ svc.backupSvc.WithMetrics(svc.metrics)
38+ }
39+ if svc.volumeBackupSvc != nil {
40+ svc.volumeBackupSvc.WithMetrics(svc.metrics)
41+ }
42+}
43+
44+// seedBackupMetrics reports the last success of every target from
45+// storage, so the metric is correct right after a restart. It only feeds
46+// the metrics state (mutex-guarded), so it may run alongside backups.
47+// Failures are logged, never fatal.
48+func seedBackupMetrics(ctx context.Context, svc *services, log zerowrap.Logger) {
49+ if svc == nil || svc.metrics == nil {
50+ return
51+ }
52+ if svc.backupSvc != nil {
53+ if err := svc.backupSvc.SeedMetrics(ctx); err != nil {
54+ log.Warn().Err(err).Msg("failed to seed database backup metrics")
55+ }
56+ }
57+ if svc.volumeBackupSvc != nil {
58+ if err := svc.volumeBackupSvc.SeedMetrics(ctx); err != nil {
59+ log.Warn().Err(err).Msg("failed to seed volume backup metrics")
60+ }
61+ }
62+}
63+
64+// observeSchedulerNextRun exports the scheduler entry's next run, read at
65+// each metrics collection.
66+func observeSchedulerNextRun(obs nextRunObserver, kind domain.BackupKind, scheduler *cronSvc.Scheduler, id string) {
67+ if obs == nil || scheduler == nil {
68+ return
69+ }
70+ obs.ObserveBackupNextRun(kind, func() time.Time { return nextRunOf(scheduler, id) })
71+}
@@ -0,0 +1,71 @@
1+package app
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/zerowrap"
8+
9+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
10+ "github.com/bnema/gordon/internal/domain"
11+ cronSvc "github.com/bnema/gordon/internal/usecase/cron"
12+)
13+
14+// nextRunObserver exports the next planned run of one backup kind.
15+type nextRunObserver interface {
16+ ObserveBackupNextRun(kind domain.BackupKind, next func() time.Time)
17+}
18+
19+// nextRunObserverPort converts a possibly nil metrics adapter into an
20+// interface that is nil when metrics are disabled. A typed nil must not
21+// leak into the interface.
22+func nextRunObserverPort(metrics *telemetry.Metrics) nextRunObserver {
23+ if metrics == nil {
24+ return nil
25+ }
26+ return metrics
27+}
28+
29+// wireBackupMetrics injects backup metrics into the backup services. It
30+// must run before any backup can start, so no run reads the field while
31+// it is written.
32+func wireBackupMetrics(svc *services) {
33+ if svc == nil || svc.metrics == nil {
34+ return
35+ }
36+ if svc.backupSvc != nil {
37+ svc.backupSvc.WithMetrics(svc.metrics)
38+ }
39+ if svc.volumeBackupSvc != nil {
40+ svc.volumeBackupSvc.WithMetrics(svc.metrics)
41+ }
42+}
43+
44+// seedBackupMetrics reports the last success of every target from
45+// storage, so the metric is correct right after a restart. It only feeds
46+// the metrics state (mutex-guarded), so it may run alongside backups.
47+// Failures are logged, never fatal.
48+func seedBackupMetrics(ctx context.Context, svc *services, log zerowrap.Logger) {
49+ if svc == nil || svc.metrics == nil {
50+ return
51+ }
52+ if svc.backupSvc != nil {
53+ if err := svc.backupSvc.SeedMetrics(ctx); err != nil {
54+ log.Warn().Err(err).Msg("failed to seed database backup metrics")
55+ }
56+ }
57+ if svc.volumeBackupSvc != nil {
58+ if err := svc.volumeBackupSvc.SeedMetrics(ctx); err != nil {
59+ log.Warn().Err(err).Msg("failed to seed volume backup metrics")
60+ }
61+ }
62+}
63+
64+// observeSchedulerNextRun exports the scheduler entry's next run, read at
65+// each metrics collection.
66+func observeSchedulerNextRun(obs nextRunObserver, kind domain.BackupKind, scheduler *cronSvc.Scheduler, id string) {
67+ if obs == nil || scheduler == nil {
68+ return
69+ }
70+ obs.ObserveBackupNextRun(kind, func() time.Time { return nextRunOf(scheduler, id) })
71+}
A internal/app/backup_metrics_wiring_test.go +49 -0
@@ -0,0 +1,49 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+ cronSvc "github.com/bnema/gordon/internal/usecase/cron"
14+)
15+
16+type fakeNextRunObserver struct {
17+ got map[domain.BackupKind]func() time.Time
18+}
19+
20+func (f *fakeNextRunObserver) ObserveBackupNextRun(kind domain.BackupKind, next func() time.Time) {
21+ if f.got == nil {
22+ f.got = map[domain.BackupKind]func() time.Time{}
23+ }
24+ f.got[kind] = next
25+}
26+
27+func TestObserveSchedulerNextRun_ReadsTheLiveEntry(t *testing.T) {
28+ scheduler := cronSvc.NewScheduler(zerowrap.Default())
29+ require.NoError(t, scheduler.Add("volume-backup-scheduler", "Volume Backups",
30+ domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error { return nil }))
31+
32+ obs := &fakeNextRunObserver{}
33+ observeSchedulerNextRun(obs, domain.BackupKindVolume, scheduler, "volume-backup-scheduler")
34+
35+ require.Contains(t, obs.got, domain.BackupKindVolume)
36+ next := obs.got[domain.BackupKindVolume]()
37+ assert.Equal(t, nextRunOf(scheduler, "volume-backup-scheduler"), next)
38+ assert.True(t, next.After(time.Now()))
39+}
40+
41+func TestObserveSchedulerNextRun_NilSafe(t *testing.T) {
42+ scheduler := cronSvc.NewScheduler(zerowrap.Default())
43+ assert.NotPanics(t, func() {
44+ observeSchedulerNextRun(nil, domain.BackupKindVolume, scheduler, "x")
45+ })
46+ obs := &fakeNextRunObserver{}
47+ observeSchedulerNextRun(obs, domain.BackupKindVolume, nil, "x")
48+ assert.Empty(t, obs.got)
49+}
@@ -0,0 +1,49 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+ cronSvc "github.com/bnema/gordon/internal/usecase/cron"
14+)
15+
16+type fakeNextRunObserver struct {
17+ got map[domain.BackupKind]func() time.Time
18+}
19+
20+func (f *fakeNextRunObserver) ObserveBackupNextRun(kind domain.BackupKind, next func() time.Time) {
21+ if f.got == nil {
22+ f.got = map[domain.BackupKind]func() time.Time{}
23+ }
24+ f.got[kind] = next
25+}
26+
27+func TestObserveSchedulerNextRun_ReadsTheLiveEntry(t *testing.T) {
28+ scheduler := cronSvc.NewScheduler(zerowrap.Default())
29+ require.NoError(t, scheduler.Add("volume-backup-scheduler", "Volume Backups",
30+ domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error { return nil }))
31+
32+ obs := &fakeNextRunObserver{}
33+ observeSchedulerNextRun(obs, domain.BackupKindVolume, scheduler, "volume-backup-scheduler")
34+
35+ require.Contains(t, obs.got, domain.BackupKindVolume)
36+ next := obs.got[domain.BackupKindVolume]()
37+ assert.Equal(t, nextRunOf(scheduler, "volume-backup-scheduler"), next)
38+ assert.True(t, next.After(time.Now()))
39+}
40+
41+func TestObserveSchedulerNextRun_NilSafe(t *testing.T) {
42+ scheduler := cronSvc.NewScheduler(zerowrap.Default())
43+ assert.NotPanics(t, func() {
44+ observeSchedulerNextRun(nil, domain.BackupKindVolume, scheduler, "x")
45+ })
46+ obs := &fakeNextRunObserver{}
47+ observeSchedulerNextRun(obs, domain.BackupKindVolume, nil, "x")
48+ assert.Empty(t, obs.got)
49+}
M internal/app/run.go +3 -0
@@ -296,6 +296,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
296296
297297 startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log)
298298
299+ // Seeding lists stored backups (S3 for volumes); never delay startup.
300+ go seedBackupMetrics(ctx, svc, log)
301+
299302 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
300303 if err != nil {
301304 cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
@@ -296,6 +296,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
296 296
297 startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log)297 startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log)
298 298
299+ // Seeding lists stored backups (S3 for volumes); never delay startup.
300+ go seedBackupMetrics(ctx, svc, log)
301+
299 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)302 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
300 if err != nil {303 if err != nil {
301 cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)304 cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
M internal/app/schedulers.go +2 -0
@@ -27,6 +27,7 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
2727 }
2828 if backupScheduler != nil {
2929 schedulers = append(schedulers, backupScheduler)
30+ observeSchedulerNextRun(nextRunObserverPort(svc.metrics), domain.BackupKindDatabase, backupScheduler, "backup-scheduler")
3031 }
3132
3233 volumeBackupScheduler, err := startVolumeBackupScheduler(ctx, cfg, svc, log)
@@ -36,6 +37,7 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
3637 }
3738 if volumeBackupScheduler != nil {
3839 schedulers = append(schedulers, volumeBackupScheduler)
40+ observeSchedulerNextRun(nextRunObserverPort(svc.metrics), domain.BackupKindVolume, volumeBackupScheduler, "volume-backup-scheduler")
3941 }
4042
4143 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
@@ -27,6 +27,7 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
27 }27 }
28 if backupScheduler != nil {28 if backupScheduler != nil {
29 schedulers = append(schedulers, backupScheduler)29 schedulers = append(schedulers, backupScheduler)
30+ observeSchedulerNextRun(nextRunObserverPort(svc.metrics), domain.BackupKindDatabase, backupScheduler, "backup-scheduler")
30 }31 }
31 32
32 volumeBackupScheduler, err := startVolumeBackupScheduler(ctx, cfg, svc, log)33 volumeBackupScheduler, err := startVolumeBackupScheduler(ctx, cfg, svc, log)
@@ -36,6 +37,7 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
36 }37 }
37 if volumeBackupScheduler != nil {38 if volumeBackupScheduler != nil {
38 schedulers = append(schedulers, volumeBackupScheduler)39 schedulers = append(schedulers, volumeBackupScheduler)
40+ observeSchedulerNextRun(nextRunObserverPort(svc.metrics), domain.BackupKindVolume, volumeBackupScheduler, "volume-backup-scheduler")
39 }41 }
40 42
41 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {43 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
M internal/app/services.go +1 -0
@@ -483,6 +483,7 @@ func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
483483 svc.registrySvc.SetMetrics(gordonMetrics)
484484 svc.eventBus.SetMetrics(gordonMetrics)
485485 svc.metrics = gordonMetrics
486+ wireBackupMetrics(svc)
486487 }
487488
488489 // observeManagedContainers exports the managed-container gauge from app
@@ -483,6 +483,7 @@ func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
483 svc.registrySvc.SetMetrics(gordonMetrics)483 svc.registrySvc.SetMetrics(gordonMetrics)
484 svc.eventBus.SetMetrics(gordonMetrics)484 svc.eventBus.SetMetrics(gordonMetrics)
485 svc.metrics = gordonMetrics485 svc.metrics = gordonMetrics
486+ wireBackupMetrics(svc)
486 }487 }
487 488
488 // observeManagedContainers exports the managed-container gauge from app489 // observeManagedContainers exports the managed-container gauge from app
A internal/boundaries/out/backup_metrics.go +19 -0
@@ -0,0 +1,19 @@
1+package out
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
8+
9+// BackupMetrics records backup outcomes.
10+// Implementations must be safe for concurrent use.
11+type BackupMetrics interface {
12+ // RecordBackupRun counts one finished backup run. A successful run
13+ // also becomes the target's last success.
14+ RecordBackupRun(ctx context.Context, run domain.BackupRunRecord)
15+ // ObserveStoredBackup reports an existing stored backup as a success
16+ // without counting a run, so the last success survives a restart.
17+ // Older records never replace a newer one.
18+ ObserveStoredBackup(run domain.BackupRunRecord)
19+}
@@ -0,0 +1,19 @@
1+package out
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
8+
9+// BackupMetrics records backup outcomes.
10+// Implementations must be safe for concurrent use.
11+type BackupMetrics interface {
12+ // RecordBackupRun counts one finished backup run. A successful run
13+ // also becomes the target's last success.
14+ RecordBackupRun(ctx context.Context, run domain.BackupRunRecord)
15+ // ObserveStoredBackup reports an existing stored backup as a success
16+ // without counting a run, so the last success survives a restart.
17+ // Older records never replace a newer one.
18+ ObserveStoredBackup(run domain.BackupRunRecord)
19+}
M internal/domain/backup.go +22 -0
@@ -85,6 +85,28 @@ type BackupResult struct {
8585 Duration time.Duration
8686 }
8787
88+// BackupKind tells database dumps and volume archives apart in metrics.
89+type BackupKind string
90+
91+const (
92+ BackupKindDatabase BackupKind = "database"
93+ BackupKindVolume BackupKind = "volume"
94+)
95+
96+// BackupRunRecord is the outcome of one backup, as reported to metrics.
97+// Target is the database name for a dump and the declared volume name for
98+// an archive.
99+type BackupRunRecord struct {
100+ Kind BackupKind
101+ App string
102+ Service string
103+ Target string
104+ Succeeded bool
105+ Duration time.Duration
106+ SizeBytes int64
107+ FinishedAt time.Time
108+}
109+
88110 // DatabaseBackupJob represents a logical database backup operation.
89111 type DatabaseBackupJob = BackupJob
90112
@@ -85,6 +85,28 @@ type BackupResult struct {
85 Duration time.Duration85 Duration time.Duration
86 }86 }
87 87
88+// BackupKind tells database dumps and volume archives apart in metrics.
89+type BackupKind string
90+
91+const (
92+ BackupKindDatabase BackupKind = "database"
93+ BackupKindVolume BackupKind = "volume"
94+)
95+
96+// BackupRunRecord is the outcome of one backup, as reported to metrics.
97+// Target is the database name for a dump and the declared volume name for
98+// an archive.
99+type BackupRunRecord struct {
100+ Kind BackupKind
101+ App string
102+ Service string
103+ Target string
104+ Succeeded bool
105+ Duration time.Duration
106+ SizeBytes int64
107+ FinishedAt time.Time
108+}
109+
88 // DatabaseBackupJob represents a logical database backup operation.110 // DatabaseBackupJob represents a logical database backup operation.
89 type DatabaseBackupJob = BackupJob111 type DatabaseBackupJob = BackupJob
90 112
A internal/usecase/backup/metrics_test.go +177 -0
@@ -0,0 +1,177 @@
1+package backup
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/boundaries/out"
17+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+// recordingMetrics is a concurrency-safe out.BackupMetrics fake.
22+type recordingMetrics struct {
23+ mu sync.Mutex
24+ runs []domain.BackupRunRecord
25+ stored []domain.BackupRunRecord
26+}
27+
28+func (r *recordingMetrics) RecordBackupRun(_ context.Context, run domain.BackupRunRecord) {
29+ r.mu.Lock()
30+ defer r.mu.Unlock()
31+ r.runs = append(r.runs, run)
32+}
33+
34+func (r *recordingMetrics) ObserveStoredBackup(run domain.BackupRunRecord) {
35+ r.mu.Lock()
36+ defer r.mu.Unlock()
37+ r.stored = append(r.stored, run)
38+}
39+
40+var _ out.BackupMetrics = (*recordingMetrics)(nil)
41+
42+func TestVolumeService_RecordsRunMetrics(t *testing.T) {
43+ ctx := context.Background()
44+ state := outmocks.NewMockAppStateReader(t)
45+ state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
46+ state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
47+ state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
48+ "api": volumeSpec("api", "data", "/data"),
49+ }, "ctr-api"), true, nil).Once()
50+
51+ metrics := &recordingMetrics{}
52+ svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, true).WithMetrics(metrics)
53+
54+ require.NoError(t, svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily))
55+ require.Len(t, metrics.runs, 1)
56+ run := metrics.runs[0]
57+ assert.Equal(t, domain.BackupKindVolume, run.Kind)
58+ assert.Equal(t, "shop", run.App)
59+ assert.Equal(t, "api", run.Service)
60+ assert.Equal(t, "data", run.Target)
61+ assert.True(t, run.Succeeded)
62+ assert.Equal(t, int64(len("archive")), run.SizeBytes)
63+ assert.False(t, run.FinishedAt.IsZero())
64+}
65+
66+func TestVolumeService_RecordsFailedRun(t *testing.T) {
67+ ctx := context.Background()
68+ state := outmocks.NewMockAppStateReader(t)
69+ state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
70+ state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
71+ "api": volumeSpec("api", "data", "/data"),
72+ }, "ctr-api"), true, nil).Once()
73+
74+ metrics := &recordingMetrics{}
75+ exporter := &fakeVolumeArchiveExporter{err: errors.New("helper failed")}
76+ svc := volumeTestService(t, state, exporter, &fakeVolumeBackupStorage{}, true).WithMetrics(metrics)
77+
78+ _, err := svc.RunVolumeBackups(ctx, "shop", "api", "data")
79+ require.Error(t, err)
80+ require.Len(t, metrics.runs, 1)
81+ assert.False(t, metrics.runs[0].Succeeded)
82+ assert.Equal(t, "data", metrics.runs[0].Target)
83+}
84+
85+func TestVolumeService_SeedMetricsFromStoredArchives(t *testing.T) {
86+ ctx := context.Background()
87+ started := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
88+ storage := &fakeVolumeBackupStorage{jobs: []domain.VolumeBackupJob{
89+ {App: "shop", Service: "api", VolumeName: "data", Status: domain.BackupStatusCompleted, StartedAt: started, SizeBytes: 42},
90+ }}
91+ metrics := &recordingMetrics{}
92+ svc := volumeTestService(t, outmocks.NewMockAppStateReader(t), &fakeVolumeArchiveExporter{}, storage, true).WithMetrics(metrics)
93+
94+ require.NoError(t, svc.SeedMetrics(ctx))
95+ require.Len(t, metrics.stored, 1)
96+ assert.Equal(t, domain.BackupRunRecord{
97+ Kind: domain.BackupKindVolume, App: "shop", Service: "api", Target: "data",
98+ Succeeded: true, SizeBytes: 42, FinishedAt: started,
99+ }, metrics.stored[0])
100+ assert.Empty(t, metrics.runs)
101+}
102+
103+func TestService_RecordsRunMetrics(t *testing.T) {
104+ ctx := context.Background()
105+ state := outmocks.NewMockAppStateReader(t)
106+ state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
107+ state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
108+ "api": appSpecWithDatabase("api", "orders", "daily"),
109+ }, "ctr-api"), true, nil).Once()
110+
111+ runtime, storage, svc := backupTestService(t, state)
112+ metrics := &recordingMetrics{}
113+ svc.WithMetrics(metrics)
114+ runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).
115+ Return(&out.ExecResult{ExitCode: 1, Stderr: []byte("boom")}, nil).Once()
116+ runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
117+ return strings.HasPrefix(cmd[2], "rm -f ")
118+ })).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
119+
120+ _, err := svc.RunBackup(ctx, "shop", "api", "orders")
121+ require.Error(t, err)
122+ require.Len(t, metrics.runs, 1)
123+ assert.Equal(t, domain.BackupRunRecord{
124+ Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders",
125+ Succeeded: false, Duration: metrics.runs[0].Duration, FinishedAt: metrics.runs[0].FinishedAt,
126+ }, metrics.runs[0])
127+ _ = storage
128+}
129+
130+func TestService_RecordsSuccessfulRun(t *testing.T) {
131+ ctx := context.Background()
132+ state := outmocks.NewMockAppStateReader(t)
133+ state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
134+ state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
135+ "api": appSpecWithDatabase("api", "orders", "daily"),
136+ }, "ctr-api"), true, nil).Once()
137+
138+ runtime, storage, svc := backupTestService(t, state)
139+ metrics := &recordingMetrics{}
140+ svc.WithMetrics(metrics)
141+ runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).Return(&out.ExecResult{ExitCode: 0}, nil).Twice()
142+ runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).
143+ Return(io.NopCloser(strings.NewReader("dump")), nil).Once()
144+ storage.EXPECT().Store(mock.Anything, "shop", "api", "orders", domain.BackupSchedule(""), mock.Anything, mock.Anything).
145+ RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, _ time.Time, data io.Reader) (string, error) {
146+ _, err := io.Copy(io.Discard, data)
147+ return "/b/file.bak", err
148+ }).Once()
149+
150+ _, err := svc.RunBackup(ctx, "shop", "api", "orders")
151+ require.NoError(t, err)
152+ require.Len(t, metrics.runs, 1)
153+ assert.True(t, metrics.runs[0].Succeeded)
154+ assert.Equal(t, int64(len("dump")), metrics.runs[0].SizeBytes)
155+}
156+
157+func TestService_SeedMetricsFromStoredDumps(t *testing.T) {
158+ ctx := context.Background()
159+ state := outmocks.NewMockAppStateReader(t)
160+ state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
161+ started := time.Date(2026, 10, 17, 2, 0, 0, 0, time.UTC)
162+
163+ _, storage, svc := backupTestService(t, state)
164+ metrics := &recordingMetrics{}
165+ svc.WithMetrics(metrics)
166+ storage.EXPECT().List(mock.Anything, "shop", mock.Anything).Return([]domain.BackupJob{{
167+ App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted,
168+ StartedAt: started, SizeBytes: 7,
169+ }}, nil).Once()
170+
171+ require.NoError(t, svc.SeedMetrics(ctx))
172+ require.Len(t, metrics.stored, 1)
173+ assert.Equal(t, domain.BackupRunRecord{
174+ Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders",
175+ Succeeded: true, SizeBytes: 7, FinishedAt: started,
176+ }, metrics.stored[0])
177+}
@@ -0,0 +1,177 @@
1+package backup
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/boundaries/out"
17+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+// recordingMetrics is a concurrency-safe out.BackupMetrics fake.
22+type recordingMetrics struct {
23+ mu sync.Mutex
24+ runs []domain.BackupRunRecord
25+ stored []domain.BackupRunRecord
26+}
27+
28+func (r *recordingMetrics) RecordBackupRun(_ context.Context, run domain.BackupRunRecord) {
29+ r.mu.Lock()
30+ defer r.mu.Unlock()
31+ r.runs = append(r.runs, run)
32+}
33+
34+func (r *recordingMetrics) ObserveStoredBackup(run domain.BackupRunRecord) {
35+ r.mu.Lock()
36+ defer r.mu.Unlock()
37+ r.stored = append(r.stored, run)
38+}
39+
40+var _ out.BackupMetrics = (*recordingMetrics)(nil)
41+
42+func TestVolumeService_RecordsRunMetrics(t *testing.T) {
43+ ctx := context.Background()
44+ state := outmocks.NewMockAppStateReader(t)
45+ state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
46+ state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
47+ state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
48+ "api": volumeSpec("api", "data", "/data"),
49+ }, "ctr-api"), true, nil).Once()
50+
51+ metrics := &recordingMetrics{}
52+ svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, true).WithMetrics(metrics)
53+
54+ require.NoError(t, svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily))
55+ require.Len(t, metrics.runs, 1)
56+ run := metrics.runs[0]
57+ assert.Equal(t, domain.BackupKindVolume, run.Kind)
58+ assert.Equal(t, "shop", run.App)
59+ assert.Equal(t, "api", run.Service)
60+ assert.Equal(t, "data", run.Target)
61+ assert.True(t, run.Succeeded)
62+ assert.Equal(t, int64(len("archive")), run.SizeBytes)
63+ assert.False(t, run.FinishedAt.IsZero())
64+}
65+
66+func TestVolumeService_RecordsFailedRun(t *testing.T) {
67+ ctx := context.Background()
68+ state := outmocks.NewMockAppStateReader(t)
69+ state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
70+ state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
71+ "api": volumeSpec("api", "data", "/data"),
72+ }, "ctr-api"), true, nil).Once()
73+
74+ metrics := &recordingMetrics{}
75+ exporter := &fakeVolumeArchiveExporter{err: errors.New("helper failed")}
76+ svc := volumeTestService(t, state, exporter, &fakeVolumeBackupStorage{}, true).WithMetrics(metrics)
77+
78+ _, err := svc.RunVolumeBackups(ctx, "shop", "api", "data")
79+ require.Error(t, err)
80+ require.Len(t, metrics.runs, 1)
81+ assert.False(t, metrics.runs[0].Succeeded)
82+ assert.Equal(t, "data", metrics.runs[0].Target)
83+}
84+
85+func TestVolumeService_SeedMetricsFromStoredArchives(t *testing.T) {
86+ ctx := context.Background()
87+ started := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
88+ storage := &fakeVolumeBackupStorage{jobs: []domain.VolumeBackupJob{
89+ {App: "shop", Service: "api", VolumeName: "data", Status: domain.BackupStatusCompleted, StartedAt: started, SizeBytes: 42},
90+ }}
91+ metrics := &recordingMetrics{}
92+ svc := volumeTestService(t, outmocks.NewMockAppStateReader(t), &fakeVolumeArchiveExporter{}, storage, true).WithMetrics(metrics)
93+
94+ require.NoError(t, svc.SeedMetrics(ctx))
95+ require.Len(t, metrics.stored, 1)
96+ assert.Equal(t, domain.BackupRunRecord{
97+ Kind: domain.BackupKindVolume, App: "shop", Service: "api", Target: "data",
98+ Succeeded: true, SizeBytes: 42, FinishedAt: started,
99+ }, metrics.stored[0])
100+ assert.Empty(t, metrics.runs)
101+}
102+
103+func TestService_RecordsRunMetrics(t *testing.T) {
104+ ctx := context.Background()
105+ state := outmocks.NewMockAppStateReader(t)
106+ state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
107+ state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
108+ "api": appSpecWithDatabase("api", "orders", "daily"),
109+ }, "ctr-api"), true, nil).Once()
110+
111+ runtime, storage, svc := backupTestService(t, state)
112+ metrics := &recordingMetrics{}
113+ svc.WithMetrics(metrics)
114+ runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).
115+ Return(&out.ExecResult{ExitCode: 1, Stderr: []byte("boom")}, nil).Once()
116+ runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
117+ return strings.HasPrefix(cmd[2], "rm -f ")
118+ })).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
119+
120+ _, err := svc.RunBackup(ctx, "shop", "api", "orders")
121+ require.Error(t, err)
122+ require.Len(t, metrics.runs, 1)
123+ assert.Equal(t, domain.BackupRunRecord{
124+ Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders",
125+ Succeeded: false, Duration: metrics.runs[0].Duration, FinishedAt: metrics.runs[0].FinishedAt,
126+ }, metrics.runs[0])
127+ _ = storage
128+}
129+
130+func TestService_RecordsSuccessfulRun(t *testing.T) {
131+ ctx := context.Background()
132+ state := outmocks.NewMockAppStateReader(t)
133+ state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
134+ state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
135+ "api": appSpecWithDatabase("api", "orders", "daily"),
136+ }, "ctr-api"), true, nil).Once()
137+
138+ runtime, storage, svc := backupTestService(t, state)
139+ metrics := &recordingMetrics{}
140+ svc.WithMetrics(metrics)
141+ runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).Return(&out.ExecResult{ExitCode: 0}, nil).Twice()
142+ runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).
143+ Return(io.NopCloser(strings.NewReader("dump")), nil).Once()
144+ storage.EXPECT().Store(mock.Anything, "shop", "api", "orders", domain.BackupSchedule(""), mock.Anything, mock.Anything).
145+ RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, _ time.Time, data io.Reader) (string, error) {
146+ _, err := io.Copy(io.Discard, data)
147+ return "/b/file.bak", err
148+ }).Once()
149+
150+ _, err := svc.RunBackup(ctx, "shop", "api", "orders")
151+ require.NoError(t, err)
152+ require.Len(t, metrics.runs, 1)
153+ assert.True(t, metrics.runs[0].Succeeded)
154+ assert.Equal(t, int64(len("dump")), metrics.runs[0].SizeBytes)
155+}
156+
157+func TestService_SeedMetricsFromStoredDumps(t *testing.T) {
158+ ctx := context.Background()
159+ state := outmocks.NewMockAppStateReader(t)
160+ state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
161+ started := time.Date(2026, 10, 17, 2, 0, 0, 0, time.UTC)
162+
163+ _, storage, svc := backupTestService(t, state)
164+ metrics := &recordingMetrics{}
165+ svc.WithMetrics(metrics)
166+ storage.EXPECT().List(mock.Anything, "shop", mock.Anything).Return([]domain.BackupJob{{
167+ App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted,
168+ StartedAt: started, SizeBytes: 7,
169+ }}, nil).Once()
170+
171+ require.NoError(t, svc.SeedMetrics(ctx))
172+ require.Len(t, metrics.stored, 1)
173+ assert.Equal(t, domain.BackupRunRecord{
174+ Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders",
175+ Succeeded: true, SizeBytes: 7, FinishedAt: started,
176+ }, metrics.stored[0])
177+}
M internal/usecase/backup/service.go +44 -0
@@ -28,6 +28,7 @@ type Service struct {
2828 config domain.BackupConfig
2929 log zerowrap.Logger
3030 state out.AppStateReader
31+ metrics out.BackupMetrics
3132 }
3233
3334 // NewService creates a backup service.
@@ -52,6 +53,32 @@ func (s *Service) WithAppState(state out.AppStateReader) *Service {
5253 return s
5354 }
5455
56+// WithMetrics wires the backup metrics recorder. Without it no metric is
57+// recorded.
58+func (s *Service) WithMetrics(metrics out.BackupMetrics) *Service {
59+ s.metrics = metrics
60+ return s
61+}
62+
63+// SeedMetrics reports the newest stored dump of every target as its last
64+// success, so the metric survives a restart. It counts no run.
65+func (s *Service) SeedMetrics(ctx context.Context) error {
66+ if s.metrics == nil {
67+ return nil
68+ }
69+ jobs, err := s.ListBackups(ctx, "")
70+ if err != nil {
71+ return fmt.Errorf("seed backup metrics: %w", err)
72+ }
73+ for _, job := range jobs {
74+ s.metrics.ObserveStoredBackup(domain.BackupRunRecord{
75+ Kind: domain.BackupKindDatabase, App: job.App, Service: job.Service, Target: job.DBName,
76+ Succeeded: true, SizeBytes: job.SizeBytes, FinishedAt: job.StartedAt,
77+ })
78+ }
79+ return nil
80+}
81+
5582 // Targets returns every declared database target of one app, ordered by
5683 // service then database.
5784 func (s *Service) Targets(ctx context.Context, app string) ([]domain.DatabaseTarget, error) {
@@ -212,7 +239,24 @@ func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) {
212239
213240 // runTarget runs one declared database backup through the service
214241 // container that hosts it.
242+// runTarget dumps one target and records the outcome in metrics.
215243 func (s *Service) runTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule) (*domain.BackupResult, error) {
244+ started := time.Now()
245+ result, err := s.dumpTarget(ctx, target, schedule)
246+ if s.metrics != nil {
247+ run := domain.BackupRunRecord{
248+ Kind: domain.BackupKindDatabase, App: target.App, Service: target.Service, Target: target.Database,
249+ Succeeded: err == nil, Duration: time.Since(started), FinishedAt: time.Now().UTC(),
250+ }
251+ if result != nil {
252+ run.SizeBytes = result.Job.SizeBytes
253+ }
254+ s.metrics.RecordBackupRun(ctx, run)
255+ }
256+ return result, err
257+}
258+
259+func (s *Service) dumpTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule) (*domain.BackupResult, error) {
216260 if target.ContainerID == "" {
217261 return nil, fmt.Errorf("backup: app %q service %q has no active container", target.App, target.Service)
218262 }
@@ -28,6 +28,7 @@ type Service struct {
28 config domain.BackupConfig28 config domain.BackupConfig
29 log zerowrap.Logger29 log zerowrap.Logger
30 state out.AppStateReader30 state out.AppStateReader
31+ metrics out.BackupMetrics
31 }32 }
32 33
33 // NewService creates a backup service.34 // NewService creates a backup service.
@@ -52,6 +53,32 @@ func (s *Service) WithAppState(state out.AppStateReader) *Service {
52 return s53 return s
53 }54 }
54 55
56+// WithMetrics wires the backup metrics recorder. Without it no metric is
57+// recorded.
58+func (s *Service) WithMetrics(metrics out.BackupMetrics) *Service {
59+ s.metrics = metrics
60+ return s
61+}
62+
63+// SeedMetrics reports the newest stored dump of every target as its last
64+// success, so the metric survives a restart. It counts no run.
65+func (s *Service) SeedMetrics(ctx context.Context) error {
66+ if s.metrics == nil {
67+ return nil
68+ }
69+ jobs, err := s.ListBackups(ctx, "")
70+ if err != nil {
71+ return fmt.Errorf("seed backup metrics: %w", err)
72+ }
73+ for _, job := range jobs {
74+ s.metrics.ObserveStoredBackup(domain.BackupRunRecord{
75+ Kind: domain.BackupKindDatabase, App: job.App, Service: job.Service, Target: job.DBName,
76+ Succeeded: true, SizeBytes: job.SizeBytes, FinishedAt: job.StartedAt,
77+ })
78+ }
79+ return nil
80+}
81+
55 // Targets returns every declared database target of one app, ordered by82 // Targets returns every declared database target of one app, ordered by
56 // service then database.83 // service then database.
57 func (s *Service) Targets(ctx context.Context, app string) ([]domain.DatabaseTarget, error) {84 func (s *Service) Targets(ctx context.Context, app string) ([]domain.DatabaseTarget, error) {
@@ -212,7 +239,24 @@ func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) {
212 239
213 // runTarget runs one declared database backup through the service240 // runTarget runs one declared database backup through the service
214 // container that hosts it.241 // container that hosts it.
242+// runTarget dumps one target and records the outcome in metrics.
215 func (s *Service) runTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule) (*domain.BackupResult, error) {243 func (s *Service) runTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule) (*domain.BackupResult, error) {
244+ started := time.Now()
245+ result, err := s.dumpTarget(ctx, target, schedule)
246+ if s.metrics != nil {
247+ run := domain.BackupRunRecord{
248+ Kind: domain.BackupKindDatabase, App: target.App, Service: target.Service, Target: target.Database,
249+ Succeeded: err == nil, Duration: time.Since(started), FinishedAt: time.Now().UTC(),
250+ }
251+ if result != nil {
252+ run.SizeBytes = result.Job.SizeBytes
253+ }
254+ s.metrics.RecordBackupRun(ctx, run)
255+ }
256+ return result, err
257+}
258+
259+func (s *Service) dumpTarget(ctx context.Context, target domain.DatabaseTarget, schedule domain.BackupSchedule) (*domain.BackupResult, error) {
216 if target.ContainerID == "" {260 if target.ContainerID == "" {
217 return nil, fmt.Errorf("backup: app %q service %q has no active container", target.App, target.Service)261 return nil, fmt.Errorf("backup: app %q service %q has no active container", target.App, target.Service)
218 }262 }
M internal/usecase/backup/volume_service.go +40 -0
@@ -26,6 +26,7 @@ type VolumeService struct {
2626 config domain.VolumeBackupConfig
2727 log zerowrap.Logger
2828 state out.AppStateReader
29+ metrics out.BackupMetrics
2930
3031 mu sync.Mutex
3132 recent map[string]domain.VolumeBackupJob
@@ -48,6 +49,32 @@ func (s *VolumeService) WithAppState(state out.AppStateReader) *VolumeService {
4849 return s
4950 }
5051
52+// WithMetrics wires the backup metrics recorder. Without it no metric is
53+// recorded.
54+func (s *VolumeService) WithMetrics(metrics out.BackupMetrics) *VolumeService {
55+ s.metrics = metrics
56+ return s
57+}
58+
59+// SeedMetrics reports the newest stored archive of every target as its
60+// last success, so the metric survives a restart. It counts no run.
61+func (s *VolumeService) SeedMetrics(ctx context.Context) error {
62+ if s.metrics == nil {
63+ return nil
64+ }
65+ jobs, err := s.storage.ListVolumeArchives(ctx, "")
66+ if err != nil {
67+ return fmt.Errorf("seed volume backup metrics: %w", err)
68+ }
69+ for _, job := range jobs {
70+ s.metrics.ObserveStoredBackup(domain.BackupRunRecord{
71+ Kind: domain.BackupKindVolume, App: job.App, Service: job.Service, Target: job.VolumeName,
72+ Succeeded: true, SizeBytes: job.SizeBytes, FinishedAt: job.StartedAt,
73+ })
74+ }
75+ return nil
76+}
77+
5178 // VolumeTargets returns every declared volume target of one app.
5279 func (s *VolumeService) VolumeTargets(ctx context.Context, app string) ([]domain.VolumeBackupTarget, error) {
5380 if s.state == nil {
@@ -214,7 +241,20 @@ func (s *VolumeService) runVolumeBackupsConcurrently(ctx context.Context, target
214241 return jobs
215242 }
216243
244+// runVolumeBackup archives one target and records the outcome in metrics.
217245 func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
246+ job := s.archiveVolume(ctx, target)
247+ if s.metrics != nil {
248+ s.metrics.RecordBackupRun(ctx, domain.BackupRunRecord{
249+ Kind: domain.BackupKindVolume, App: target.App, Service: target.Service, Target: target.VolumeName,
250+ Succeeded: job.Status == domain.BackupStatusCompleted, Duration: job.CompletedAt.Sub(job.StartedAt),
251+ SizeBytes: job.SizeBytes, FinishedAt: job.CompletedAt,
252+ })
253+ }
254+ return job
255+}
256+
257+func (s *VolumeService) archiveVolume(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
218258 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
219259 "app": target.App,
220260 "service": target.Service,
@@ -26,6 +26,7 @@ type VolumeService struct {
26 config domain.VolumeBackupConfig26 config domain.VolumeBackupConfig
27 log zerowrap.Logger27 log zerowrap.Logger
28 state out.AppStateReader28 state out.AppStateReader
29+ metrics out.BackupMetrics
29 30
30 mu sync.Mutex31 mu sync.Mutex
31 recent map[string]domain.VolumeBackupJob32 recent map[string]domain.VolumeBackupJob
@@ -48,6 +49,32 @@ func (s *VolumeService) WithAppState(state out.AppStateReader) *VolumeService {
48 return s49 return s
49 }50 }
50 51
52+// WithMetrics wires the backup metrics recorder. Without it no metric is
53+// recorded.
54+func (s *VolumeService) WithMetrics(metrics out.BackupMetrics) *VolumeService {
55+ s.metrics = metrics
56+ return s
57+}
58+
59+// SeedMetrics reports the newest stored archive of every target as its
60+// last success, so the metric survives a restart. It counts no run.
61+func (s *VolumeService) SeedMetrics(ctx context.Context) error {
62+ if s.metrics == nil {
63+ return nil
64+ }
65+ jobs, err := s.storage.ListVolumeArchives(ctx, "")
66+ if err != nil {
67+ return fmt.Errorf("seed volume backup metrics: %w", err)
68+ }
69+ for _, job := range jobs {
70+ s.metrics.ObserveStoredBackup(domain.BackupRunRecord{
71+ Kind: domain.BackupKindVolume, App: job.App, Service: job.Service, Target: job.VolumeName,
72+ Succeeded: true, SizeBytes: job.SizeBytes, FinishedAt: job.StartedAt,
73+ })
74+ }
75+ return nil
76+}
77+
51 // VolumeTargets returns every declared volume target of one app.78 // VolumeTargets returns every declared volume target of one app.
52 func (s *VolumeService) VolumeTargets(ctx context.Context, app string) ([]domain.VolumeBackupTarget, error) {79 func (s *VolumeService) VolumeTargets(ctx context.Context, app string) ([]domain.VolumeBackupTarget, error) {
53 if s.state == nil {80 if s.state == nil {
@@ -214,7 +241,20 @@ func (s *VolumeService) runVolumeBackupsConcurrently(ctx context.Context, target
214 return jobs241 return jobs
215 }242 }
216 243
244+// runVolumeBackup archives one target and records the outcome in metrics.
217 func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {245 func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
246+ job := s.archiveVolume(ctx, target)
247+ if s.metrics != nil {
248+ s.metrics.RecordBackupRun(ctx, domain.BackupRunRecord{
249+ Kind: domain.BackupKindVolume, App: target.App, Service: target.Service, Target: target.VolumeName,
250+ Succeeded: job.Status == domain.BackupStatusCompleted, Duration: job.CompletedAt.Sub(job.StartedAt),
251+ SizeBytes: job.SizeBytes, FinishedAt: job.CompletedAt,
252+ })
253+ }
254+ return job
255+}
256+
257+func (s *VolumeService) archiveVolume(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
218 ctx = zerowrap.CtxWithFields(ctx, map[string]any{258 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
219 "app": target.App,259 "app": target.App,
220 "service": target.Service,260 "service": target.Service,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close