main 0857610

backup_metrics_test.go RawHistoryFolder

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}

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