kubo/test/cli/telemetry_test.go
Hector Sanjuan 46324f085d fix: disable telemetry in test profile (#10931)
* Tests: disable telemetry in tests by default

Disable the plugin in cli tests and sharness by default. Enable only in
telemetry tests.

There are cases when tests get stuck or get killed and leave daemons hanging around. We don't want to be getting telemetry from those.

* sharness: attempt to fix

* sharness: add missing --bool flag

* fix(ci): add omitempty to Plugin.Config field

The sharness problem is that when the telemetry plugin is configured
initially with 'ipfs config --bool', it creates a structure without
the 'Config: null' field, but when the config is copied and replaced,
it expects the structure to be preserved.

Adding omitempty ensures the Config field is omitted from JSON when
nil, making the config structure consistent between initial creation
and replacement operations.

---------

Co-authored-by: Marcin Rataj <lidel@lidel.org>
(cherry picked from commit 15f723a15e)
2025-08-27 16:26:50 +02:00

315 lines
8.9 KiB
Go

package cli
import (
"encoding/json"
"io"
"maps"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/ipfs/kubo/test/cli/harness"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTelemetry(t *testing.T) {
t.Parallel()
t.Run("opt-out via environment variable", func(t *testing.T) {
t.Parallel()
// Create a new node
node := harness.NewT(t).NewNode().Init()
node.SetIPFSConfig("Plugins.Plugins.telemetry.Disabled", false)
// Set the opt-out environment variable
node.Runner.Env["IPFS_TELEMETRY"] = "off"
node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
// Capture daemon output
stdout := &harness.Buffer{}
stderr := &harness.Buffer{}
// Start daemon with output capture
node.StartDaemonWithReq(harness.RunRequest{
CmdOpts: []harness.CmdOpt{
harness.RunWithStdout(stdout),
harness.RunWithStderr(stderr),
},
}, "")
time.Sleep(500 * time.Millisecond)
// Get daemon output
output := stdout.String() + stderr.String()
// Check that telemetry is disabled
assert.Contains(t, output, "telemetry disabled via opt-out", "Expected telemetry disabled message")
// Stop daemon
node.StopDaemon()
// Verify UUID file was not created or was removed
uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
_, err := os.Stat(uuidPath)
assert.True(t, os.IsNotExist(err), "UUID file should not exist when opted out")
})
t.Run("opt-out via config", func(t *testing.T) {
t.Parallel()
// Create a new node
node := harness.NewT(t).NewNode().Init()
node.SetIPFSConfig("Plugins.Plugins.telemetry.Disabled", false)
// Set opt-out via config
node.IPFS("config", "Plugins.Plugins.telemetry.Config.Mode", "off")
// Enable debug logging
node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
// Capture daemon output
stdout := &harness.Buffer{}
stderr := &harness.Buffer{}
// Start daemon with output capture
node.StartDaemonWithReq(harness.RunRequest{
CmdOpts: []harness.CmdOpt{
harness.RunWithStdout(stdout),
harness.RunWithStderr(stderr),
},
}, "")
time.Sleep(500 * time.Millisecond)
// Get daemon output
output := stdout.String() + stderr.String()
// Check that telemetry is disabled
assert.Contains(t, output, "telemetry disabled via opt-out", "Expected telemetry disabled message")
assert.Contains(t, output, "telemetry collection skipped: opted out", "Expected telemetry skipped message")
// Stop daemon
node.StopDaemon()
// Verify UUID file was not created or was removed
uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
_, err := os.Stat(uuidPath)
assert.True(t, os.IsNotExist(err), "UUID file should not exist when opted out")
})
t.Run("opt-out removes existing UUID file", func(t *testing.T) {
t.Parallel()
// Create a new node
node := harness.NewT(t).NewNode().Init()
node.SetIPFSConfig("Plugins.Plugins.telemetry.Disabled", false)
// Create a UUID file manually to simulate previous telemetry run
uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
testUUID := "test-uuid-12345"
err := os.WriteFile(uuidPath, []byte(testUUID), 0600)
require.NoError(t, err, "Failed to create test UUID file")
// Verify file exists
_, err = os.Stat(uuidPath)
require.NoError(t, err, "UUID file should exist before opt-out")
// Set the opt-out environment variable
node.Runner.Env["IPFS_TELEMETRY"] = "off"
node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
// Capture daemon output
stdout := &harness.Buffer{}
stderr := &harness.Buffer{}
// Start daemon with output capture
node.StartDaemonWithReq(harness.RunRequest{
CmdOpts: []harness.CmdOpt{
harness.RunWithStdout(stdout),
harness.RunWithStderr(stderr),
},
}, "")
time.Sleep(500 * time.Millisecond)
// Get daemon output
output := stdout.String() + stderr.String()
// Check that UUID file was removed
assert.Contains(t, output, "removed existing telemetry UUID file due to opt-out", "Expected UUID removal message")
// Stop daemon
node.StopDaemon()
// Verify UUID file was removed
_, err = os.Stat(uuidPath)
assert.True(t, os.IsNotExist(err), "UUID file should be removed after opt-out")
})
t.Run("telemetry enabled shows info message", func(t *testing.T) {
t.Parallel()
// Create a new node
node := harness.NewT(t).NewNode().Init()
node.SetIPFSConfig("Plugins.Plugins.telemetry.Disabled", false)
// Capture daemon output
stdout := &harness.Buffer{}
stderr := &harness.Buffer{}
// Don't set opt-out, so telemetry will be enabled
// This should trigger the info message on first run
node.StartDaemonWithReq(harness.RunRequest{
CmdOpts: []harness.CmdOpt{
harness.RunWithStdout(stdout),
harness.RunWithStderr(stderr),
},
}, "")
time.Sleep(500 * time.Millisecond)
// Get daemon output
output := stdout.String() + stderr.String()
// First run - should show info message
assert.Contains(t, output, "Anonymous telemetry")
assert.Contains(t, output, "No data sent yet", "Expected no data sent message")
assert.Contains(t, output, "To opt-out before collection starts", "Expected opt-out instructions")
assert.Contains(t, output, "Learn more:", "Expected learn more link")
// Stop daemon
node.StopDaemon()
// Verify UUID file was created
uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
_, err := os.Stat(uuidPath)
assert.NoError(t, err, "UUID file should exist when daemon started without telemetry opt-out")
})
t.Run("telemetry schema regression guard", func(t *testing.T) {
t.Parallel()
// Define the exact set of expected telemetry fields
// This list must be updated whenever telemetry fields change
expectedFields := []string{
"uuid",
"agent_version",
"private_network",
"bootstrappers_custom",
"repo_size_bucket",
"uptime_bucket",
"reprovider_strategy",
"routing_type",
"routing_accelerated_dht_client",
"routing_delegated_count",
"autonat_service_mode",
"autonat_reachability",
"swarm_enable_hole_punching",
"swarm_circuit_addresses",
"swarm_ipv4_public_addresses",
"swarm_ipv6_public_addresses",
"auto_tls_auto_wss",
"auto_tls_domain_suffix_custom",
"autoconf",
"autoconf_custom",
"discovery_mdns_enabled",
"platform_os",
"platform_arch",
"platform_containerized",
"platform_vm",
}
// Channel to receive captured telemetry data
telemetryChan := make(chan map[string]interface{}, 1)
// Create a mock HTTP server to capture telemetry
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
var telemetryData map[string]interface{}
if err := json.Unmarshal(body, &telemetryData); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Send captured data through channel
select {
case telemetryChan <- telemetryData:
default:
}
w.WriteHeader(http.StatusOK)
}))
defer mockServer.Close()
// Create a new node
node := harness.NewT(t).NewNode().Init()
node.SetIPFSConfig("Plugins.Plugins.telemetry.Disabled", false)
// Configure telemetry with a very short delay for testing
node.IPFS("config", "Plugins.Plugins.telemetry.Config.Delay", "100ms")
node.IPFS("config", "Plugins.Plugins.telemetry.Config.Endpoint", mockServer.URL)
// Enable debug logging to see what's being sent
node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
// Start daemon
node.StartDaemon()
defer node.StopDaemon()
// Wait for telemetry to be sent (configured delay + buffer)
select {
case telemetryData := <-telemetryChan:
receivedFields := slices.Collect(maps.Keys(telemetryData))
slices.Sort(expectedFields)
slices.Sort(receivedFields)
// Fast path: check if fields match exactly
if !slices.Equal(expectedFields, receivedFields) {
var missingFields, unexpectedFields []string
for _, field := range expectedFields {
if _, ok := telemetryData[field]; !ok {
missingFields = append(missingFields, field)
}
}
expectedSet := make(map[string]struct{}, len(expectedFields))
for _, f := range expectedFields {
expectedSet[f] = struct{}{}
}
for field := range telemetryData {
if _, ok := expectedSet[field]; !ok {
unexpectedFields = append(unexpectedFields, field)
}
}
t.Fatalf("Telemetry field mismatch:\n"+
" Missing fields: %v\n"+
" Unexpected fields: %v\n"+
" Note: Update expectedFields list in this test when adding/removing telemetry fields",
missingFields, unexpectedFields)
}
t.Logf("Telemetry field validation passed: %d fields verified", len(expectedFields))
case <-time.After(5 * time.Second):
t.Fatal("Timeout waiting for telemetry data to be sent")
}
})
}