master
go 317 lines 8.96 KB
Raw
1 package cli
2
3 import (
4 "encoding/json"
5 "io"
6 "maps"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "slices"
12 "testing"
13 "time"
14
15 "github.com/ipfs/kubo/test/cli/harness"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18 )
19
20 func TestTelemetry(t *testing.T) {
21 t.Parallel()
22
23 t.Run("opt-out via environment variable", func(t *testing.T) {
24 t.Parallel()
25
26 // Create a new node
27 node := harness.NewT(t).NewNode().Init()
28 node.SetIPFSConfig("Plugins.Plugins.telemetry.Disabled", false)
29
30 // Set the opt-out environment variable
31 node.Runner.Env["IPFS_TELEMETRY"] = "off"
32 node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
33
34 // Capture daemon output
35 stdout := &harness.Buffer{}
36 stderr := &harness.Buffer{}
37
38 // Start daemon with output capture
39 node.StartDaemonWithReq(harness.RunRequest{
40 CmdOpts: []harness.CmdOpt{
41 harness.RunWithStdout(stdout),
42 harness.RunWithStderr(stderr),
43 },
44 }, "")
45
46 time.Sleep(500 * time.Millisecond)
47
48 // Get daemon output
49 output := stdout.String() + stderr.String()
50
51 // Check that telemetry is disabled
52 assert.Contains(t, output, "telemetry disabled via opt-out", "Expected telemetry disabled message")
53
54 // Stop daemon
55 node.StopDaemon()
56
57 // Verify UUID file was not created or was removed
58 uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
59 _, err := os.Stat(uuidPath)
60 assert.True(t, os.IsNotExist(err), "UUID file should not exist when opted out")
61 })
62
63 t.Run("opt-out via config", func(t *testing.T) {
64 t.Parallel()
65
66 // Create a new node
67 node := harness.NewT(t).NewNode().Init()
68 node.SetIPFSConfig("Plugins.Plugins.telemetry.Disabled", false)
69
70 // Set opt-out via config
71 node.IPFS("config", "Plugins.Plugins.telemetry.Config.Mode", "off")
72
73 // Enable debug logging
74 node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
75
76 // Capture daemon output
77 stdout := &harness.Buffer{}
78 stderr := &harness.Buffer{}
79
80 // Start daemon with output capture
81 node.StartDaemonWithReq(harness.RunRequest{
82 CmdOpts: []harness.CmdOpt{
83 harness.RunWithStdout(stdout),
84 harness.RunWithStderr(stderr),
85 },
86 }, "")
87
88 time.Sleep(500 * time.Millisecond)
89
90 // Get daemon output
91 output := stdout.String() + stderr.String()
92
93 // Check that telemetry is disabled
94 assert.Contains(t, output, "telemetry disabled via opt-out", "Expected telemetry disabled message")
95 assert.Contains(t, output, "telemetry collection skipped: opted out", "Expected telemetry skipped message")
96
97 // Stop daemon
98 node.StopDaemon()
99
100 // Verify UUID file was not created or was removed
101 uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
102 _, err := os.Stat(uuidPath)
103 assert.True(t, os.IsNotExist(err), "UUID file should not exist when opted out")
104 })
105
106 t.Run("opt-out removes existing UUID file", func(t *testing.T) {
107 t.Parallel()
108
109 // Create a new node
110 node := harness.NewT(t).NewNode().Init()
111 node.SetIPFSConfig("Plugins.Plugins.telemetry.Disabled", false)
112
113 // Create a UUID file manually to simulate previous telemetry run
114 uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
115 testUUID := "test-uuid-12345"
116 err := os.WriteFile(uuidPath, []byte(testUUID), 0600)
117 require.NoError(t, err, "Failed to create test UUID file")
118
119 // Verify file exists
120 _, err = os.Stat(uuidPath)
121 require.NoError(t, err, "UUID file should exist before opt-out")
122
123 // Set the opt-out environment variable
124 node.Runner.Env["IPFS_TELEMETRY"] = "off"
125 node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
126
127 // Capture daemon output
128 stdout := &harness.Buffer{}
129 stderr := &harness.Buffer{}
130
131 // Start daemon with output capture
132 node.StartDaemonWithReq(harness.RunRequest{
133 CmdOpts: []harness.CmdOpt{
134 harness.RunWithStdout(stdout),
135 harness.RunWithStderr(stderr),
136 },
137 }, "")
138
139 time.Sleep(500 * time.Millisecond)
140
141 // Get daemon output
142 output := stdout.String() + stderr.String()
143
144 // Check that UUID file was removed
145 assert.Contains(t, output, "removed existing telemetry UUID file due to opt-out", "Expected UUID removal message")
146
147 // Stop daemon
148 node.StopDaemon()
149
150 // Verify UUID file was removed
151 _, err = os.Stat(uuidPath)
152 assert.True(t, os.IsNotExist(err), "UUID file should be removed after opt-out")
153 })
154
155 t.Run("telemetry enabled shows info message", func(t *testing.T) {
156 t.Parallel()
157
158 // Create a new node
159 node := harness.NewT(t).NewNode().Init()
160 node.SetIPFSConfig("Plugins.Plugins.telemetry.Disabled", false)
161
162 // Capture daemon output
163 stdout := &harness.Buffer{}
164 stderr := &harness.Buffer{}
165
166 // Don't set opt-out, so telemetry will be enabled
167 // This should trigger the info message on first run
168 node.StartDaemonWithReq(harness.RunRequest{
169 CmdOpts: []harness.CmdOpt{
170 harness.RunWithStdout(stdout),
171 harness.RunWithStderr(stderr),
172 },
173 }, "")
174
175 time.Sleep(500 * time.Millisecond)
176
177 // Get daemon output
178 output := stdout.String() + stderr.String()
179
180 // First run - should show info message
181 assert.Contains(t, output, "Anonymous telemetry")
182 assert.Contains(t, output, "No data sent yet", "Expected no data sent message")
183 assert.Contains(t, output, "To opt-out before collection starts", "Expected opt-out instructions")
184 assert.Contains(t, output, "Learn more:", "Expected learn more link")
185
186 // Stop daemon
187 node.StopDaemon()
188
189 // Verify UUID file was created
190 uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
191 _, err := os.Stat(uuidPath)
192 assert.NoError(t, err, "UUID file should exist when daemon started without telemetry opt-out")
193 })
194
195 t.Run("telemetry schema regression guard", func(t *testing.T) {
196 t.Parallel()
197
198 // Define the exact set of expected telemetry fields
199 // This list must be updated whenever telemetry fields change
200 expectedFields := []string{
201 "uuid",
202 "agent_version",
203 "private_network",
204 "bootstrappers_custom",
205 "repo_size_bucket",
206 "uptime_bucket",
207 "reprovider_strategy",
208 "provide_dht_sweep_enabled",
209 "provide_dht_interval_custom",
210 "provide_dht_max_workers_custom",
211 "routing_type",
212 "routing_accelerated_dht_client",
213 "routing_delegated_count",
214 "autonat_service_mode",
215 "autonat_reachability",
216 "swarm_enable_hole_punching",
217 "swarm_circuit_addresses",
218 "swarm_ipv4_public_addresses",
219 "swarm_ipv6_public_addresses",
220 "auto_tls_auto_wss",
221 "auto_tls_domain_suffix_custom",
222 "autoconf",
223 "autoconf_custom",
224 "discovery_mdns_enabled",
225 "platform_os",
226 "platform_arch",
227 "platform_containerized",
228 "platform_vm",
229 }
230
231 // Channel to receive captured telemetry data
232 telemetryChan := make(chan map[string]any, 1)
233
234 // Create a mock HTTP server to capture telemetry
235 mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
236 if r.Method != "POST" {
237 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
238 return
239 }
240
241 body, err := io.ReadAll(r.Body)
242 if err != nil {
243 http.Error(w, "Failed to read body", http.StatusBadRequest)
244 return
245 }
246
247 var telemetryData map[string]any
248 if err := json.Unmarshal(body, &telemetryData); err != nil {
249 http.Error(w, "Invalid JSON", http.StatusBadRequest)
250 return
251 }
252
253 // Send captured data through channel
254 select {
255 case telemetryChan <- telemetryData:
256 default:
257 }
258
259 w.WriteHeader(http.StatusOK)
260 }))
261 defer mockServer.Close()
262
263 // Create a new node
264 node := harness.NewT(t).NewNode().Init()
265 node.SetIPFSConfig("Plugins.Plugins.telemetry.Disabled", false)
266
267 // Configure telemetry with a very short delay for testing
268 node.IPFS("config", "Plugins.Plugins.telemetry.Config.Delay", "100ms")
269 node.IPFS("config", "Plugins.Plugins.telemetry.Config.Endpoint", mockServer.URL)
270
271 // Enable debug logging to see what's being sent
272 node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
273
274 // Start daemon
275 node.StartDaemon()
276 defer node.StopDaemon()
277
278 // Wait for telemetry to be sent (configured delay + buffer)
279 select {
280 case telemetryData := <-telemetryChan:
281 receivedFields := slices.Collect(maps.Keys(telemetryData))
282 slices.Sort(expectedFields)
283 slices.Sort(receivedFields)
284
285 // Fast path: check if fields match exactly
286 if !slices.Equal(expectedFields, receivedFields) {
287 var missingFields, unexpectedFields []string
288 for _, field := range expectedFields {
289 if _, ok := telemetryData[field]; !ok {
290 missingFields = append(missingFields, field)
291 }
292 }
293
294 expectedSet := make(map[string]struct{}, len(expectedFields))
295 for _, f := range expectedFields {
296 expectedSet[f] = struct{}{}
297 }
298 for field := range telemetryData {
299 if _, ok := expectedSet[field]; !ok {
300 unexpectedFields = append(unexpectedFields, field)
301 }
302 }
303
304 t.Fatalf("Telemetry field mismatch:\n"+
305 " Missing fields: %v\n"+
306 " Unexpected fields: %v\n"+
307 " Note: Update expectedFields list in this test when adding/removing telemetry fields",
308 missingFields, unexpectedFields)
309 }
310
311 t.Logf("Telemetry field validation passed: %d fields verified", len(expectedFields))
312
313 case <-time.After(5 * time.Second):
314 t.Fatal("Timeout waiting for telemetry data to be sent")
315 }
316 })
317 }