kubo/test/cli/telemetry_test.go
Hector Sanjuan 4255cc3889
feat: telemetry plugin (#10866)
* Initial pass at Telemetry plugin

Currently, IP Shipyard, with the help of Probelab, monitor and extract
Amino/IPFS public network metrics with the use of DHT crawlers and
bootstrappers (via peerlog plugin). For example, we log all peer IDs seen and
their AgentVersion/Addresses obtained from the `identify` protocol, which
provides insights into protocol usage, total number of peers etc.

We would like to increase the ability to obtain more insights from the network
by collecting some more information in the future, but also to give users more
control over this collection (i.e. opt-out). The information collected will
not allow unique identification of anyone and is only used for aggregation.

Now, this PR explores a way of moving in this direction:

* A new "telemetry" fx plugin is in charge of dealing with telemetry
* The FX plugin allows to plug and make decisions / take actions during the setup phase:
  * We can inspect whether we are using Private Networks before the libp2p.Host has been initialized.
  * We can send telemetry after the libp2p Host is initialized.
  * Everything is self-contained. Custom builds can remove the plugin altogether without needing to surgically edit the code.

As for behaviour:

* The user can opt-in/out via EnvVar, file in the repo path or plugin configuration.
* Users on private networks or with custom bootstrappers are detected, offered a wall of text explaining why we need telemetry and invited to opt-in. Opt-out happens otherwise on a timeout (with no input). Their preferences are stored.
* Users on standard settings are opted-in by default. This is the status quo in Kubo already, except they don't get a chance to opt out.

The telemetry libp2p protocol is yet to be defined, but expect something similar to identify, with a protobuf being pushed to bootstrappers or to a specific telemetry node that we define. In the case of pnets, this will be done with a temporary peer.

* checkpoint

* telemetry plugin: second pass

* On first run it generates a UUID and shows a message to the user.
* UUID is persistend to "telemetry_uuid"
* Sends telemetry 1 minute after boot and every 24h
* LogEvent is the thing containing all the telemetry that is sent
* Opt-out possible via env-var or plugin configuration

* Telemetry: add changelog and environment variable documentation

* docs: improved daemon message

making it more obvious nothing was sent yet
and that user had 15m to out-out

plus some debug logs that confirm opt-out

* refactor: rename IPFS_TELEMETRY_MODE to IPFS_TELEMETRY

* fix: add User-Agent header to telemetry requests

---------

Co-authored-by: Andrew Gillis <11790789+gammazero@users.noreply.github.com>
Co-authored-by: Marcin Rataj <lidel@lidel.org>
2025-08-18 20:46:05 +02:00

185 lines
5.1 KiB
Go

package cli
import (
"os"
"path/filepath"
"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()
// 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()
// 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()
// 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()
// 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")
})
}