1package telemetry
2
3import (
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
18func 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
29func 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
42func 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.
48func 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
59func 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 ref := domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "forgejo", Service: "forgejo", Target: "forgejo-data"}
65 m.RecordBackupRun(ctx, domain.BackupRunRecord{
66 BackupTargetRef: ref, Succeeded: true, Duration: 40 * time.Second, SizeBytes: 1024, StartedAt: done,
67 })
68 m.RecordBackupRun(ctx, domain.BackupRunRecord{
69 BackupTargetRef: ref, Succeeded: false, Duration: time.Second, StartedAt: done.Add(time.Hour),
70 })
71
72 data := collect(t, reader)
73
74 runs, ok := data["gordon.backup.runs"].(metricdata.Sum[int64])
75 require.True(t, ok)
76 results := map[string]int64{}
77 for _, dp := range runs.DataPoints {
78 assert.Equal(t, "volume", attrValue(dp.Attributes, "kind"))
79 assert.Equal(t, "forgejo", attrValue(dp.Attributes, "app"))
80 assert.Equal(t, "forgejo", attrValue(dp.Attributes, "service"))
81 assert.Equal(t, "forgejo-data", attrValue(dp.Attributes, "target"))
82 results[attrValue(dp.Attributes, "result")] = dp.Value
83 }
84 assert.Equal(t, map[string]int64{"success": 1, "failure": 1}, results)
85
86 hist, ok := data["gordon.backup.duration_seconds"].(metricdata.Histogram[float64])
87 require.True(t, ok)
88 require.Len(t, hist.DataPoints, 1)
89 assert.Equal(t, uint64(2), hist.DataPoints[0].Count)
90 assert.InDelta(t, 41.0, hist.DataPoints[0].Sum, 0.001)
91
92 // A failure never moves the last-success timestamp or size.
93 assert.Equal(t, map[string]float64{"forgejo-data": float64(done.Unix())},
94 gaugeByTarget(t, data["gordon.backup.last_success_timestamp_seconds"]))
95 assert.Equal(t, map[string]float64{"forgejo-data": 1024},
96 gaugeByTarget(t, data["gordon.backup.last_size_bytes"]))
97}
98
99func TestObserveStoredBackup_KeepsTheNewestSuccess(t *testing.T) {
100 m, reader := newTestMetrics(t)
101 newer := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
102 older := newer.Add(-24 * time.Hour)
103 rec := domain.BackupRunRecord{
104 BackupTargetRef: domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: "shop", Service: "db", Target: "orders"},
105 Succeeded: true,
106 }
107
108 rec.StartedAt, rec.SizeBytes = newer, 200
109 m.ObserveStoredBackup(rec)
110 rec.StartedAt, 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
124func TestRetainBackupTargets_ForgetsUndeclaredTargetsOfThatKindOnly(t *testing.T) {
125 m, reader := newTestMetrics(t)
126 at := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
127 keep := domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "shop", Service: "api", Target: "data"}
128 gone := domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "old", Service: "api", Target: "cache"}
129 db := domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: "old", Service: "db", Target: "orders"}
130 for _, ref := range []domain.BackupTargetRef{keep, gone, db} {
131 m.ObserveStoredBackup(domain.BackupRunRecord{BackupTargetRef: ref, Succeeded: true, StartedAt: at})
132 }
133
134 m.RetainBackupTargets(domain.BackupKindVolume, []domain.BackupTargetRef{keep}, time.Now())
135
136 assert.Equal(t, map[string]float64{"data": float64(at.Unix()), "orders": float64(at.Unix())},
137 gaugeByTarget(t, collect(t, reader)["gordon.backup.last_success_timestamp_seconds"]))
138}
139
140// TestRetainBackupTargets_KeepsSuccessRecordedAfterTheSnapshot covers a
141// target declared and backed up while seeding read an older declaration.
142func TestRetainBackupTargets_KeepsSuccessRecordedAfterTheSnapshot(t *testing.T) {
143 m, reader := newTestMetrics(t)
144 clock := time.Date(2026, 10, 17, 8, 0, 0, 0, time.UTC)
145 m.backups.now = func() time.Time { return clock }
146 snapshot := clock
147 clock = clock.Add(time.Second)
148 fresh := domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "new", Service: "api", Target: "data"}
149 m.RecordBackupRun(context.Background(), domain.BackupRunRecord{BackupTargetRef: fresh, Succeeded: true, StartedAt: clock})
150
151 m.RetainBackupTargets(domain.BackupKindVolume, nil, snapshot)
152
153 assert.Equal(t, map[string]float64{"data": float64(clock.Unix())},
154 gaugeByTarget(t, collect(t, reader)["gordon.backup.last_success_timestamp_seconds"]))
155}
156
157func TestObserveBackupNextRun(t *testing.T) {
158 m, reader := newTestMetrics(t)
159 next := time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC)
160 m.ObserveBackupNextRun(domain.BackupKindVolume, func() time.Time { return next })
161 m.ObserveBackupNextRun(domain.BackupKindDatabase, func() time.Time { return time.Time{} })
162
163 gauge, ok := collect(t, reader)["gordon.backup.next_run_timestamp_seconds"].(metricdata.Gauge[float64])
164 require.True(t, ok)
165 require.Len(t, gauge.DataPoints, 1, "an unknown next run is not exported")
166 assert.Equal(t, "volume", attrValue(gauge.DataPoints[0].Attributes, "kind"))
167 assert.Equal(t, float64(next.Unix()), gauge.DataPoints[0].Value)
168}