detached c7d0fa6

test(container): cover managed metric delta behavior and document global series

commit
c7d0fa69117a84a6c4e01be901bcb79a8837de96
author
bnema · · 2026-02-11 20:41 UTC
parent
6a6fcae
signature
unsigned
2 files · +159 -1
M docs/config/telemetry.md +1 -1
@@ -100,7 +100,7 @@ Attributes: `domain`, `image`
100100 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |
101101 | `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |
102102
103-Attributes: `domain`, `source` (restarts only: `monitor` or `api`)
103+Attributes: `source` (restarts only: `monitor` or `api`); `gordon.container.managed` is a global gauge with no attributes
104104
105105 ### Registry
106106
@@ -100,7 +100,7 @@ Attributes: `domain`, `image`
100 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |100 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |
101 | `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |101 | `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |
102 102
103-Attributes: `domain`, `source` (restarts only: `monitor` or `api`)103+Attributes: `source` (restarts only: `monitor` or `api`); `gordon.container.managed` is a global gauge with no attributes
104 104
105 ### Registry105 ### Registry
106 106
M internal/usecase/container/service_test.go +158 -0
@@ -11,7 +11,12 @@ import (
1111 "github.com/bnema/zerowrap"
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+ "go.opentelemetry.io/otel"
16+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
17+ "go.opentelemetry.io/otel/sdk/metric/metricdata"
1418
19+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
1520 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1621 "github.com/bnema/gordon/internal/domain"
1722 )
@@ -20,6 +25,159 @@ func testContext() context.Context {
2025 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
2126 }
2227
28+func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {
29+ t.Helper()
30+
31+ prev := otel.GetMeterProvider()
32+ t.Cleanup(func() {
33+ otel.SetMeterProvider(prev)
34+ })
35+
36+ reader := sdkmetric.NewManualReader()
37+ mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
38+ otel.SetMeterProvider(mp)
39+ t.Cleanup(func() {
40+ _ = mp.Shutdown(context.Background())
41+ })
42+
43+ m, err := telemetry.NewMetrics()
44+ require.NoError(t, err)
45+ return m, reader
46+}
47+
48+func managedMetricState(t *testing.T, reader *sdkmetric.ManualReader) (int64, int, []metricdata.DataPoint[int64]) {
49+ t.Helper()
50+
51+ var rm metricdata.ResourceMetrics
52+ require.NoError(t, reader.Collect(context.Background(), &rm))
53+
54+ for _, sm := range rm.ScopeMetrics {
55+ for _, m := range sm.Metrics {
56+ if m.Name != "gordon.container.managed" {
57+ continue
58+ }
59+ sum, ok := m.Data.(metricdata.Sum[int64])
60+ require.True(t, ok)
61+ var total int64
62+ for _, dp := range sum.DataPoints {
63+ total += dp.Value
64+ }
65+ return total, len(sum.DataPoints), sum.DataPoints
66+ }
67+ }
68+
69+ return 0, 0, nil
70+}
71+
72+func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {
73+ runtime := mocks.NewMockContainerRuntime(t)
74+ envLoader := mocks.NewMockEnvLoader(t)
75+ eventBus := mocks.NewMockEventPublisher(t)
76+
77+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
78+ ctx := testContext()
79+ metrics, reader := setupMetricsTest(t)
80+ svc.SetMetrics(metrics)
81+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Twice()
82+
83+ // First deploy increments metric.
84+ svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-1"})
85+ value, seriesCount, points := managedMetricState(t, reader)
86+ assert.Equal(t, int64(1), value)
87+ assert.Equal(t, 1, seriesCount)
88+ assert.Equal(t, 0, points[0].Attributes.Len())
89+
90+ // Replacing an existing domain should not increment.
91+ svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-2"})
92+ value, seriesCount, points = managedMetricState(t, reader)
93+ assert.Equal(t, int64(1), value)
94+ assert.Equal(t, 1, seriesCount)
95+ assert.Equal(t, 0, points[0].Attributes.Len())
96+
97+ runtime.EXPECT().RemoveContainer(mock.Anything, "container-2", true).Return(nil)
98+ require.NoError(t, svc.Remove(ctx, "container-2", true))
99+
100+ value, seriesCount, points = managedMetricState(t, reader)
101+ assert.Equal(t, int64(0), value)
102+ assert.Equal(t, 1, seriesCount)
103+ assert.Equal(t, 0, points[0].Attributes.Len())
104+}
105+
106+func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T) {
107+ runtime := mocks.NewMockContainerRuntime(t)
108+ envLoader := mocks.NewMockEnvLoader(t)
109+ eventBus := mocks.NewMockEventPublisher(t)
110+
111+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
112+ ctx := testContext()
113+ metrics, reader := setupMetricsTest(t)
114+ svc.SetMetrics(metrics)
115+
116+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
117+ {
118+ ID: "container-1",
119+ Labels: map[string]string{
120+ "gordon.domain": "app1.example.com",
121+ "gordon.managed": "true",
122+ },
123+ },
124+ {
125+ ID: "container-2",
126+ Labels: map[string]string{
127+ "gordon.domain": "app2.example.com",
128+ "gordon.managed": "true",
129+ },
130+ },
131+ }, nil).Once()
132+ require.NoError(t, svc.SyncContainers(ctx))
133+
134+ value, seriesCount, points := managedMetricState(t, reader)
135+ assert.Equal(t, int64(2), value)
136+ assert.Equal(t, 1, seriesCount)
137+ assert.Equal(t, 0, points[0].Attributes.Len())
138+
139+ // No runtime change: metric should remain stable.
140+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
141+ {
142+ ID: "container-1",
143+ Labels: map[string]string{
144+ "gordon.domain": "app1.example.com",
145+ "gordon.managed": "true",
146+ },
147+ },
148+ {
149+ ID: "container-2",
150+ Labels: map[string]string{
151+ "gordon.domain": "app2.example.com",
152+ "gordon.managed": "true",
153+ },
154+ },
155+ }, nil).Once()
156+ require.NoError(t, svc.SyncContainers(ctx))
157+
158+ value, seriesCount, points = managedMetricState(t, reader)
159+ assert.Equal(t, int64(2), value)
160+ assert.Equal(t, 1, seriesCount)
161+ assert.Equal(t, 0, points[0].Attributes.Len())
162+
163+ // One container removed in runtime: metric should decrement by one.
164+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
165+ {
166+ ID: "container-1",
167+ Labels: map[string]string{
168+ "gordon.domain": "app1.example.com",
169+ "gordon.managed": "true",
170+ },
171+ },
172+ }, nil).Once()
173+ require.NoError(t, svc.SyncContainers(ctx))
174+
175+ value, seriesCount, points = managedMetricState(t, reader)
176+ assert.Equal(t, int64(1), value)
177+ assert.Equal(t, 1, seriesCount)
178+ assert.Equal(t, 0, points[0].Attributes.Len())
179+}
180+
23181 func TestService_Deploy_Success(t *testing.T) {
24182 runtime := mocks.NewMockContainerRuntime(t)
25183 envLoader := mocks.NewMockEnvLoader(t)
@@ -11,7 +11,12 @@ import (
11 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+ "go.opentelemetry.io/otel"
16+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
17+ "go.opentelemetry.io/otel/sdk/metric/metricdata"
14 18
19+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
15 "github.com/bnema/gordon/internal/boundaries/out/mocks"20 "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"21 "github.com/bnema/gordon/internal/domain"
17 )22 )
@@ -20,6 +25,159 @@ func testContext() context.Context {
20 return zerowrap.WithCtx(context.Background(), zerowrap.Default())25 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
21 }26 }
22 27
28+func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {
29+ t.Helper()
30+
31+ prev := otel.GetMeterProvider()
32+ t.Cleanup(func() {
33+ otel.SetMeterProvider(prev)
34+ })
35+
36+ reader := sdkmetric.NewManualReader()
37+ mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
38+ otel.SetMeterProvider(mp)
39+ t.Cleanup(func() {
40+ _ = mp.Shutdown(context.Background())
41+ })
42+
43+ m, err := telemetry.NewMetrics()
44+ require.NoError(t, err)
45+ return m, reader
46+}
47+
48+func managedMetricState(t *testing.T, reader *sdkmetric.ManualReader) (int64, int, []metricdata.DataPoint[int64]) {
49+ t.Helper()
50+
51+ var rm metricdata.ResourceMetrics
52+ require.NoError(t, reader.Collect(context.Background(), &rm))
53+
54+ for _, sm := range rm.ScopeMetrics {
55+ for _, m := range sm.Metrics {
56+ if m.Name != "gordon.container.managed" {
57+ continue
58+ }
59+ sum, ok := m.Data.(metricdata.Sum[int64])
60+ require.True(t, ok)
61+ var total int64
62+ for _, dp := range sum.DataPoints {
63+ total += dp.Value
64+ }
65+ return total, len(sum.DataPoints), sum.DataPoints
66+ }
67+ }
68+
69+ return 0, 0, nil
70+}
71+
72+func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {
73+ runtime := mocks.NewMockContainerRuntime(t)
74+ envLoader := mocks.NewMockEnvLoader(t)
75+ eventBus := mocks.NewMockEventPublisher(t)
76+
77+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
78+ ctx := testContext()
79+ metrics, reader := setupMetricsTest(t)
80+ svc.SetMetrics(metrics)
81+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Twice()
82+
83+ // First deploy increments metric.
84+ svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-1"})
85+ value, seriesCount, points := managedMetricState(t, reader)
86+ assert.Equal(t, int64(1), value)
87+ assert.Equal(t, 1, seriesCount)
88+ assert.Equal(t, 0, points[0].Attributes.Len())
89+
90+ // Replacing an existing domain should not increment.
91+ svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-2"})
92+ value, seriesCount, points = managedMetricState(t, reader)
93+ assert.Equal(t, int64(1), value)
94+ assert.Equal(t, 1, seriesCount)
95+ assert.Equal(t, 0, points[0].Attributes.Len())
96+
97+ runtime.EXPECT().RemoveContainer(mock.Anything, "container-2", true).Return(nil)
98+ require.NoError(t, svc.Remove(ctx, "container-2", true))
99+
100+ value, seriesCount, points = managedMetricState(t, reader)
101+ assert.Equal(t, int64(0), value)
102+ assert.Equal(t, 1, seriesCount)
103+ assert.Equal(t, 0, points[0].Attributes.Len())
104+}
105+
106+func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T) {
107+ runtime := mocks.NewMockContainerRuntime(t)
108+ envLoader := mocks.NewMockEnvLoader(t)
109+ eventBus := mocks.NewMockEventPublisher(t)
110+
111+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
112+ ctx := testContext()
113+ metrics, reader := setupMetricsTest(t)
114+ svc.SetMetrics(metrics)
115+
116+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
117+ {
118+ ID: "container-1",
119+ Labels: map[string]string{
120+ "gordon.domain": "app1.example.com",
121+ "gordon.managed": "true",
122+ },
123+ },
124+ {
125+ ID: "container-2",
126+ Labels: map[string]string{
127+ "gordon.domain": "app2.example.com",
128+ "gordon.managed": "true",
129+ },
130+ },
131+ }, nil).Once()
132+ require.NoError(t, svc.SyncContainers(ctx))
133+
134+ value, seriesCount, points := managedMetricState(t, reader)
135+ assert.Equal(t, int64(2), value)
136+ assert.Equal(t, 1, seriesCount)
137+ assert.Equal(t, 0, points[0].Attributes.Len())
138+
139+ // No runtime change: metric should remain stable.
140+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
141+ {
142+ ID: "container-1",
143+ Labels: map[string]string{
144+ "gordon.domain": "app1.example.com",
145+ "gordon.managed": "true",
146+ },
147+ },
148+ {
149+ ID: "container-2",
150+ Labels: map[string]string{
151+ "gordon.domain": "app2.example.com",
152+ "gordon.managed": "true",
153+ },
154+ },
155+ }, nil).Once()
156+ require.NoError(t, svc.SyncContainers(ctx))
157+
158+ value, seriesCount, points = managedMetricState(t, reader)
159+ assert.Equal(t, int64(2), value)
160+ assert.Equal(t, 1, seriesCount)
161+ assert.Equal(t, 0, points[0].Attributes.Len())
162+
163+ // One container removed in runtime: metric should decrement by one.
164+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
165+ {
166+ ID: "container-1",
167+ Labels: map[string]string{
168+ "gordon.domain": "app1.example.com",
169+ "gordon.managed": "true",
170+ },
171+ },
172+ }, nil).Once()
173+ require.NoError(t, svc.SyncContainers(ctx))
174+
175+ value, seriesCount, points = managedMetricState(t, reader)
176+ assert.Equal(t, int64(1), value)
177+ assert.Equal(t, 1, seriesCount)
178+ assert.Equal(t, 0, points[0].Attributes.Len())
179+}
180+
23 func TestService_Deploy_Success(t *testing.T) {181 func TestService_Deploy_Success(t *testing.T) {
24 runtime := mocks.NewMockContainerRuntime(t)182 runtime := mocks.NewMockContainerRuntime(t)
25 envLoader := mocks.NewMockEnvLoader(t)183 envLoader := mocks.NewMockEnvLoader(t)

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