diff --git a/.cspell.yml b/.cspell.yml index da6c80196..f56756a87 100644 --- a/.cspell.yml +++ b/.cspell.yml @@ -3,4 +3,4 @@ ignoreWords: - NodeCreater # This spelling is used in the fuse dependency - Boddy # One of the contributors to the project - Chris Boddy - Botto # One of the contributors to the project - Santiago Botto - - cose # dag-cose + - cose # dag-cose \ No newline at end of file diff --git a/docs/changelogs/v0.37.md b/docs/changelogs/v0.37.md index 9d2f3673b..bc48c8ff3 100644 --- a/docs/changelogs/v0.37.md +++ b/docs/changelogs/v0.37.md @@ -20,6 +20,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team. - [Improved `ipfs cid`](#improved-ipfs-cid) - [Deprecated `ipfs stats reprovide`](#deprecated-ipfs-stats-reprovide) - [๐Ÿ”„ AutoRelay now uses all connected peers for relay discovery](#-autorelay-now-uses-all-connected-peers-for-relay-discovery) + - [๐Ÿ“Š Anonymous telemetry for better feature prioritization](#-anonymous-telemetry-for-better-feature-prioritization) - [๐Ÿ“ฆ๏ธ Important dependency updates](#-important-dependency-updates) - [๐Ÿ“ Changelog](#-changelog) - [๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors](#-contributors) @@ -136,7 +137,54 @@ The `ipfs stats reprovide` command has moved to `ipfs provide stat`. This was do AutoRelay's relay discovery now includes all connected peers as potential relay candidates, not just peers discovered through the DHT. This allows peers connected via HTTP routing and manual `ipfs swarm connect` commands to serve as relays, improving connectivity for nodes using non-DHT routing configurations. -#### ๐Ÿ“ฆ๏ธ Important dependency updates +#### ๐Ÿ“Š Anonymous telemetry for better feature prioritization + +Per a suggestion from the IPFS Foundation, Kubo now sends optional anonymized telemetry information to Shipyard [maintainers](https://github.com/ipshipyard/roadmaps/issues/20). + +**Privacy first**: The telemetry system collects only anonymous data - no personally identifiable information, file paths, or content data. A random UUID is generated on first run for anonymous identification. Users are notified before any data is sent and have time to opt-out. + +**Why**: We want to better understand Kubo usage across the ecosystem so we can better direct funding and work efforts. For example, we have little insights into how many nodes are NAT'ed and rely on AutoNAT for reachability. Some of the information can be inferred by crawling the network or logging `/identify` details in the bootstrappers, but users have no way of opting out from that, so we believe it is more transparent to concentrate this functionality in one place. + +**What**: Currently, we send the following anonymous metrics: + +``` + "uuid": "", + "agent_version": "kubo/0.37.0-dev", + "private_network": false, + "bootstrappers_custom": false, + "repo_size_bucket": 1073741824, + "uptime_bucket": 86400000000000, + "reprovider_strategy": "pinned", + "routing_type": "auto", + "routing_accelerated_dht_client": false, + "routing_delegated_count": 0, + "autonat_service_mode": "enabled", + "autonat_reachability": "", + "swarm_enable_hole_punching": true, + "swarm_circuit_addresses": false, + "swarm_ipv4_public_addresses": true, + "swarm_ipv6_public_addresses": true, + "auto_tls_auto_wss": true, + "auto_tls_domain_suffix_custom": false, + "discovery_mdns_enabled": true, + "platform_os": "linux", + "platform_arch": "amd64", + "platform_containerized": false, + "platform_vm": false +``` + +The exact data sent for your node can be inspected by setting `GOLOG_LOG_LEVEL="telemetry=debug"`. Users will see an informative message the first time they launch a telemetry-enabled daemon, with time to opt-out before any data is collected. Telemetry data is sent every 24h, with the first collection starting 15 minutes after daemon launch. + +**User control**: You can opt-out at any time: + +- Set environment variable `IPFS_TELEMETRY=off` before starting the daemon +- Or run `ipfs config Plugins.Plugins.telemetry.Config.Mode off` and restart the daemon + +The telemetry plugin code lives in `plugin/plugins/telemetry`. + +Learn more: [`/kubo/docs/telemetry.md`](https://github.com/ipfs/kubo/blob/master/docs/telemetry.md) + +### ๐Ÿ“ฆ๏ธ Important dependency updates - update `go-libp2p` to [v0.43.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.43.0) (incl. [v0.42.1](https://github.com/libp2p/go-libp2p/releases/tag/v0.42.1)) - update `p2p-forge/client` to [v0.6.1](https://github.com/ipshipyard/p2p-forge/releases/tag/v0.6.1) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index ed18f8f3b..2942bf788 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -18,6 +18,7 @@ - [`IPFS_HTTP_ROUTERS_FILTER_PROTOCOLS`](#ipfs_http_routers_filter_protocols) - [`IPFS_CONTENT_BLOCKING_DISABLE`](#ipfs_content_blocking_disable) - [`IPFS_WAIT_REPO_LOCK`](#ipfs_wait_repo_lock) + - [`IPFS_TELEMETRY`](#ipfs_telemetry) - [`LIBP2P_TCP_REUSEPORT`](#libp2p_tcp_reuseport) - [`LIBP2P_TCP_MUX`](#libp2p_tcp_mux) - [`LIBP2P_MUX_PREFS`](#libp2p_mux_prefs) @@ -194,6 +195,22 @@ IPFS_WAIT_REPO_LOCK="15s" If the lock cannot be acquired because someone else has the lock, and `IPFS_WAIT_REPO_LOCK` is set to a valid value, then acquiring the lock is retried every second until the lock is acquired or the specified wait time has elapsed. +## `IPFS_TELEMETRY` + +Controls the behavior of the [telemetry plugin](telemetry.md). Valid values are: + +- `on`: Enables telemetry. +- `off`: Disables telemetry. +- `auto`: Like `on`, but logs an informative message about telemetry and gives user 15 minutes to opt-out before first collection. Used automatically on first run and when `IPFS_TELEMETRY` is not set. + +The mode can also be set in the config file under `Plugins.Plugins.telemetry.Config.Mode`. + +Example: + +```bash +export IPFS_TELEMETRY="off" +``` + ## `LIBP2P_TCP_REUSEPORT` Kubo tries to reuse the same source port for all connections to improve NAT diff --git a/docs/plugins.md b/docs/plugins.md index 86cfe1c51..8a388a533 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -117,6 +117,7 @@ Example: | [flatfs](https://github.com/ipfs/kubo/tree/master/plugin/plugins/flatfs) | Datastore | x | A stable filesystem-based datastore. | | [levelds](https://github.com/ipfs/kubo/tree/master/plugin/plugins/levelds) | Datastore | x | A stable, flexible datastore backend. | | [jaeger](https://github.com/ipfs/go-jaeger-plugin) | Tracing | | An opentracing backend. | +| [telemetry](https://github.com/ipfs/kubo/tree/master/plugin/plugins/telemetry) | Telemetry | x | Collects anonymized usage data for Kubo development. | * **Preloaded** plugins are built into the Kubo binary and do not need to be installed separately. At the moment, all in-tree plugins are preloaded. diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 000000000..d4bab0e1c --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,122 @@ +# Telemetry Plugin Documentation + +The **Telemetry plugin** is a feature in Kubo that collects **anonymized usage data** to help the development team better understand how the software is used, identify areas for improvement, and guide future feature development. + +This data is not personally identifiable and is used solely for the purpose of improving the Kubo project. + +--- + +## ๐Ÿ›ก๏ธ How to Control Telemetry + +The behavior of the Telemetry plugin is controlled via the environment variable [`IPFS_TELEMETRY`](environment-variables.md#ipfs_telemetry) and optionally via the `Plugins.Plugins.telemetry.Config.Mode` in the IPFS config file. + +### Available Modes + +| Mode | Description | +|----------|-----------------------------------------------------------------------------| +| `on` | **Default**. Telemetry is enabled. Data is sent periodically. | +| `off` | Telemetry is disabled. No data is sent. Any existing telemetry UUID file is removed. | +| `auto` | Like `on`, but logs an informative message about the telemetry and gives user 15 minutes to opt-out before first collection. This mode is automatically used on the first run when `IPFS_TELEMETRY` is not set and telemetry UUID is not found (not generated yet). The informative message is only shown once. | + +You can set the mode in your environment: + +```bash +export IPFS_TELEMETRY="off" +``` + +Or in your IPFS config file: + +```json +{ + "Plugins": { + "Plugins": { + "telemetry": { + "Config": { + "Mode": "off" + } + } + } + } +} +``` + +--- + +## ๐Ÿ“ฆ What Data is Collected? + +The telemetry plugin collects the following anonymized data: + +### General Information +- **Agent version**: The version of Kubo being used. +- **Platform details**: Operating system, architecture, and container status. +- **Uptime**: How long the node has been running, categorized into buckets. +- **Repo size**: Categorized into buckets (e.g., 1GB, 5GB, 10GB, etc.). + +### Network Configuration +- **Private network**: Whether the node is running in a private network. +- **Bootstrap peers**: Whether custom bootstrap peers are used. +- **Routing type**: Whether the node uses DHT, IPFS, or a custom routing setup. +- **AutoNAT settings**: Whether AutoNAT is enabled and its reachability status. +- **Swarm settings**: Whether hole punching is enabled, and whether public IP addresses are used. + +### TLS and Discovery +- **AutoTLS settings**: Whether WSS is enabled and whether a custom domain suffix is used. +- **Discovery settings**: Whether mDNS is enabled. + +### Reprovider Strategy +- The strategy used for reprovider (e.g., "all", "pinned"...). + +--- + +## ๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ Privacy and Anonymization + +All data collected is: +- **Anonymized**: No personally identifiable information (PII) is sent. +- **Optional**: Users can choose to opt out at any time. +- **Secure**: Data is sent over HTTPS to a trusted endpoint. + +The telemetry UUID is stored in the IPFS repo folder and is used to identify the node across runs, but it does not contain any personal information. When you opt-out, this UUID file is automatically removed to ensure complete privacy. + +--- + +## ๐Ÿ“ฆ Contributing to the Project + +By enabling telemetry, you are helping the Kubo team improve the software for the entire community. The data is used to: + +- Prioritize feature development +- Identify performance bottlenecks +- Improve user experience + +You can always disable telemetry at any time if you change your mind. + +--- + +## ๐Ÿงช Testing Telemetry + +If you're testing telemetry locally, you can change the endpoint by setting the `Endpoint` field in the config: + +```json +{ + "Plugins": { + "Plugins": { + "telemetry": { + "Config": { + "Mode": "on", + "Endpoint": "http://localhost:8080" + } + } + } + } +} +``` + +This allows you to capture and inspect telemetry data locally. + +--- + +## ๐Ÿ“ฆ Further Reading + +For more information, see: +- [IPFS Environment Variables](docs/environment-variables.md) +- [IPFS Plugins](docs/plugins.md) +- [IPFS Configuration](docs/config.md) diff --git a/plugin/loader/preload.go b/plugin/loader/preload.go index 75e21270c..eb1bd5a6e 100644 --- a/plugin/loader/preload.go +++ b/plugin/loader/preload.go @@ -10,6 +10,7 @@ import ( pluginnopfs "github.com/ipfs/kubo/plugin/plugins/nopfs" pluginpebbleds "github.com/ipfs/kubo/plugin/plugins/pebbleds" pluginpeerlog "github.com/ipfs/kubo/plugin/plugins/peerlog" + plugintelemetry "github.com/ipfs/kubo/plugin/plugins/telemetry" ) // DO NOT EDIT THIS FILE @@ -26,4 +27,5 @@ func init() { Preload(pluginpeerlog.Plugins...) Preload(pluginfxtest.Plugins...) Preload(pluginnopfs.Plugins...) + Preload(plugintelemetry.Plugins...) } diff --git a/plugin/loader/preload_list b/plugin/loader/preload_list index 190cc65d7..80e5b9cc9 100644 --- a/plugin/loader/preload_list +++ b/plugin/loader/preload_list @@ -13,3 +13,4 @@ pebbleds github.com/ipfs/kubo/plugin/plugins/pebbleds * peerlog github.com/ipfs/kubo/plugin/plugins/peerlog * fxtest github.com/ipfs/kubo/plugin/plugins/fxtest * nopfs github.com/ipfs/kubo/plugin/plugins/nopfs * +telemetry github.com/ipfs/kubo/plugin/plugins/telemetry * diff --git a/plugin/plugins/telemetry/telemetry.go b/plugin/plugins/telemetry/telemetry.go new file mode 100644 index 000000000..0414e5098 --- /dev/null +++ b/plugin/plugins/telemetry/telemetry.go @@ -0,0 +1,560 @@ +package telemetry + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path" + "runtime" + "strings" + "time" + + "github.com/google/uuid" + logging "github.com/ipfs/go-log/v2" + ipfs "github.com/ipfs/kubo" + "github.com/ipfs/kubo/config" + "github.com/ipfs/kubo/core" + "github.com/ipfs/kubo/core/corerepo" + "github.com/ipfs/kubo/plugin" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/pnet" + multiaddr "github.com/multiformats/go-multiaddr" + manet "github.com/multiformats/go-multiaddr/net" +) + +var log = logging.Logger("telemetry") + +const ( + modeEnvVar = "IPFS_TELEMETRY" + uuidFilename = "telemetry_uuid" + endpoint = "https://telemetry.ipshipyard.dev" + sendDelay = 15 * time.Minute // delay before first telemetry collection after daemon start + sendInterval = 24 * time.Hour // interval between telemetry collections after the first one + httpTimeout = 30 * time.Second // timeout for telemetry HTTP requests +) + +type pluginMode int + +const ( + modeAuto pluginMode = iota + modeOn + modeOff +) + +// repoSizeBuckets defines size thresholds for categorizing repository sizes. +// Each value represents the upper limit of a bucket in bytes (except the last) +var repoSizeBuckets = []uint64{ + 1 << 30, // 1 GB + 5 << 30, // 5 GB + 10 << 30, // 10 GB + 100 << 30, // 100 GB + 500 << 30, // 500 GB + 1 << 40, // 1 TB + 10 << 40, // 10 TB + 11 << 40, // + anything more than 10TB falls here. +} + +var uptimeBuckets = []time.Duration{ + 1 * 24 * time.Hour, + 2 * 24 * time.Hour, + 3 * 24 * time.Hour, + 7 * 24 * time.Hour, + 14 * 24 * time.Hour, + 30 * 24 * time.Hour, + 31 * 24 * time.Hour, // + anything more than 30 days falls here. +} + +// A LogEvent is the object sent to the telemetry endpoint. +type LogEvent struct { + UUID string `json:"uuid"` + + AgentVersion string `json:"agent_version"` + + PrivateNetwork bool `json:"private_network"` + + BootstrappersCustom bool `json:"bootstrappers_custom"` + + RepoSizeBucket uint64 `json:"repo_size_bucket"` + + UptimeBucket time.Duration `json:"uptime_bucket"` + + ReproviderStrategy string `json:"reprovider_strategy"` + + RoutingType string `json:"routing_type"` + RoutingAcceleratedDHTClient bool `json:"routing_accelerated_dht_client"` + RoutingDelegatedCount int `json:"routing_delegated_count"` + + AutoNATServiceMode string `json:"autonat_service_mode"` + AutoNATReachability string `json:"autonat_reachability"` + + SwarmEnableHolePunching bool `json:"swarm_enable_hole_punching"` + SwarmCircuitAddresses bool `json:"swarm_circuit_addresses"` + SwarmIPv4PublicAddresses bool `json:"swarm_ipv4_public_addresses"` + SwarmIPv6PublicAddresses bool `json:"swarm_ipv6_public_addresses"` + + AutoTLSAutoWSS bool `json:"auto_tls_auto_wss"` + AutoTLSDomainSuffixCustom bool `json:"auto_tls_domain_suffix_custom"` + + DiscoveryMDNSEnabled bool `json:"discovery_mdns_enabled"` + + PlatformOS string `json:"platform_os"` + PlatformArch string `json:"platform_arch"` + PlatformContainerized bool `json:"platform_containerized"` + PlatformVM bool `json:"platform_vm"` +} + +var Plugins = []plugin.Plugin{ + &telemetryPlugin{}, +} + +type telemetryPlugin struct { + uuidFilename string + mode pluginMode + endpoint string + runOnce bool // test-only flag: when true, sends telemetry immediately without delay + sendDelay time.Duration + + node *core.IpfsNode + config *config.Config + event *LogEvent + startTime time.Time +} + +func (p *telemetryPlugin) Name() string { + return "telemetry" +} + +func (p *telemetryPlugin) Version() string { + return "0.0.1" +} + +func readFromConfig(cfg interface{}, key string) string { + if cfg == nil { + return "" + } + + pcfg, ok := cfg.(map[string]interface{}) + if !ok { + return "" + } + + val, ok := pcfg[key].(string) + if !ok { + return "" + } + return val +} + +func (p *telemetryPlugin) Init(env *plugin.Environment) error { + // logging.SetLogLevel("telemetry", "DEBUG") + log.Debug("telemetry plugin Init()") + p.event = &LogEvent{} + p.startTime = time.Now() + + repoPath := env.Repo + p.uuidFilename = path.Join(repoPath, uuidFilename) + + v := os.Getenv(modeEnvVar) + if v != "" { + log.Debug("mode set from env-var") + } else if pmode := readFromConfig(env.Config, "Mode"); pmode != "" { + v = pmode + log.Debug("mode set from config") + } + + // read "Delay" from the config. Parse as duration. Set p.sendDelay to it + // or set default. + if delayStr := readFromConfig(env.Config, "Delay"); delayStr != "" { + delay, err := time.ParseDuration(delayStr) + if err != nil { + log.Debug("sendDelay set from default") + p.sendDelay = sendDelay + } else { + log.Debug("sendDelay set from config") + p.sendDelay = delay + } + } else { + log.Debug("sendDelay set from default") + p.sendDelay = sendDelay + } + + p.endpoint = endpoint + if ep := readFromConfig(env.Config, "Endpoint"); ep != "" { + log.Debug("endpoint set from config", ep) + p.endpoint = ep + } + + switch v { + case "off": + p.mode = modeOff + log.Debug("telemetry disabled via opt-out") + // Remove UUID file if it exists when user opts out + if _, err := os.Stat(p.uuidFilename); err == nil { + if err := os.Remove(p.uuidFilename); err != nil { + log.Debugf("failed to remove telemetry UUID file: %s", err) + } else { + log.Debug("removed existing telemetry UUID file due to opt-out") + } + } + return nil + case "auto": + p.mode = modeAuto + default: + p.mode = modeOn + } + log.Debug("telemetry mode: ", p.mode) + return nil +} + +func (p *telemetryPlugin) loadUUID() error { + // Generate or read our UUID from disk + b, err := os.ReadFile(p.uuidFilename) + if err != nil { + if !os.IsNotExist(err) { + log.Errorf("error reading telemetry uuid from disk: %s", err) + return err + } + uid, err := uuid.NewRandom() + if err != nil { + log.Errorf("cannot generate telemetry uuid: %s", err) + return err + } + p.event.UUID = uid.String() + p.mode = modeAuto + log.Debugf("new telemetry UUID %s. Mode set to Auto", uid) + + // Write the UUID to disk + if err := os.WriteFile(p.uuidFilename, []byte(p.event.UUID), 0600); err != nil { + log.Errorf("cannot write telemetry uuid: %s", err) + return err + } + return nil + } + + v := string(b) + v = strings.TrimSpace(v) + uid, err := uuid.Parse(v) + if err != nil { + log.Errorf("cannot parse telemetry uuid: %s", err) + return err + } + log.Debugf("uuid read from disk %s", uid) + p.event.UUID = uid.String() + return nil +} + +func (p *telemetryPlugin) hasDefaultBootstrapPeers() bool { + defaultPeers := config.DefaultBootstrapAddresses + currentPeers := p.config.Bootstrap + if len(defaultPeers) != len(currentPeers) { + return false + } + peerMap := make(map[string]struct{}, len(defaultPeers)) + for _, peer := range defaultPeers { + peerMap[peer] = struct{}{} + } + for _, peer := range currentPeers { + if _, ok := peerMap[peer]; !ok { + return false + } + } + return true +} + +func (p *telemetryPlugin) showInfo() { + fmt.Printf(` + +โ„น๏ธ Anonymous telemetry will be enabled in %s + +Kubo will collect anonymous usage data to help improve the software: +โ€ข What: Feature usage and configuration (no personal data) + Use GOLOG_LOG_LEVEL="telemetry=debug" to inspect collected data +โ€ข When: First collection in %s, then every 24h +โ€ข How: HTTP POST to %s + Anonymous ID: %s + +No data sent yet. To opt-out before collection starts: +โ€ข Set environment: %s=off +โ€ข Or run: ipfs config Plugins.Plugins.telemetry.Config.Mode off +โ€ข Then restart daemon + +This message is shown only once. +Learn more: https://github.com/ipfs/kubo/blob/master/docs/telemetry.md + + +`, p.sendDelay, p.sendDelay, endpoint, p.event.UUID, modeEnvVar) +} + +// Start finishes telemetry initialization once the IpfsNode is ready, +// collects telemetry data and sends it to the endpoint. +func (p *telemetryPlugin) Start(n *core.IpfsNode) error { + // We should not be crashing the daemon due to problems with telemetry + // so this is always going to return nil and panics are going to be + // handled. + defer func() { + if r := recover(); r != nil { + log.Errorf("telemetry plugin panicked: %v", r) + } + }() + + p.node = n + cfg, err := n.Repo.Config() + if err != nil { + log.Error("error getting the repo.Config: %s", err) + return nil + } + p.config = cfg + if p.mode == modeOff { + log.Debug("telemetry collection skipped: opted out") + return nil + } + + if !n.IsDaemon || !n.IsOnline { + log.Debugf("skipping telemetry. Daemon: %t. Online: %t", n.IsDaemon, n.IsOnline) + return nil + } + + // loadUUID might switch to modeAuto when generating a new uuid + if err := p.loadUUID(); err != nil { + p.mode = modeOff + return nil + } + + if p.mode == modeAuto { + p.showInfo() + } + + // runOnce is only used in tests to send telemetry immediately. + // In production, this is always false, ensuring users get the 15-minute delay. + if p.runOnce { + p.prepareEvent() + return p.sendTelemetry() + } + + go func() { + timer := time.NewTimer(p.sendDelay) + for range timer.C { + p.prepareEvent() + if err := p.sendTelemetry(); err != nil { + log.Warnf("telemetry submission failed: %s (will retry in %s)", err, sendInterval) + } + timer.Reset(sendInterval) + } + }() + + return nil +} + +func (p *telemetryPlugin) prepareEvent() { + p.collectBasicInfo() + p.collectRoutingInfo() + p.collectAutoNATInfo() + p.collectSwarmInfo() + p.collectAutoTLSInfo() + p.collectDiscoveryInfo() + p.collectPlatformInfo() +} + +// Collects: +// * AgentVersion +// * PrivateNetwork +// * RepoSizeBucket +// * BootstrappersCustom +// * UptimeBucket +// * ReproviderStrategy +func (p *telemetryPlugin) collectBasicInfo() { + p.event.AgentVersion = ipfs.GetUserAgentVersion() + + privNet := false + if pnet.ForcePrivateNetwork { + privNet = true + } else if key, _ := p.node.Repo.SwarmKey(); key != nil { + privNet = true + } + p.event.PrivateNetwork = privNet + + p.event.BootstrappersCustom = !p.hasDefaultBootstrapPeers() + + repoSizeBucket := repoSizeBuckets[len(repoSizeBuckets)-1] + sizeStat, err := corerepo.RepoSize(context.Background(), p.node) + if err == nil { + for _, b := range repoSizeBuckets { + if sizeStat.RepoSize > b { + continue + } + repoSizeBucket = b + break + } + p.event.RepoSizeBucket = repoSizeBucket + } else { + log.Debugf("error setting sizeStat: %s", err) + } + + uptime := time.Since(p.startTime) + uptimeBucket := uptimeBuckets[len(uptimeBuckets)-1] + for _, bucket := range uptimeBuckets { + if uptime > bucket { + continue + + } + uptimeBucket = bucket + break + } + p.event.UptimeBucket = uptimeBucket + + p.event.ReproviderStrategy = p.config.Reprovider.Strategy.WithDefault(config.DefaultReproviderStrategy) +} + +func (p *telemetryPlugin) collectRoutingInfo() { + p.event.RoutingType = p.config.Routing.Type.WithDefault("auto") + p.event.RoutingAcceleratedDHTClient = p.config.Routing.AcceleratedDHTClient.WithDefault(false) + p.event.RoutingDelegatedCount = len(p.config.Routing.DelegatedRouters) +} + +type reachabilityHost interface { + Reachability() network.Reachability +} + +func (p *telemetryPlugin) collectAutoNATInfo() { + autonat := p.config.AutoNAT.ServiceMode + if autonat == config.AutoNATServiceUnset { + autonat = config.AutoNATServiceEnabled + } + autoNATSvcModeB, err := autonat.MarshalText() + if err == nil { + autoNATSvcMode := string(autoNATSvcModeB) + if autoNATSvcMode == "" { + autoNATSvcMode = "unset" + } + p.event.AutoNATServiceMode = autoNATSvcMode + } + + h := p.node.PeerHost + reachHost, ok := h.(reachabilityHost) + if ok { + p.event.AutoNATReachability = reachHost.Reachability().String() + } +} + +func (p *telemetryPlugin) collectSwarmInfo() { + p.event.SwarmEnableHolePunching = p.config.Swarm.EnableHolePunching.WithDefault(true) + + var circuitAddrs, publicIP4Addrs, publicIP6Addrs bool + for _, addr := range p.node.PeerHost.Addrs() { + if manet.IsPublicAddr(addr) { + if _, err := addr.ValueForProtocol(multiaddr.P_IP4); err == nil { + publicIP4Addrs = true + } else if _, err := addr.ValueForProtocol(multiaddr.P_IP6); err == nil { + publicIP6Addrs = true + } + } + if _, err := addr.ValueForProtocol(multiaddr.P_CIRCUIT); err == nil { + circuitAddrs = true + } + } + + p.event.SwarmCircuitAddresses = circuitAddrs + p.event.SwarmIPv4PublicAddresses = publicIP4Addrs + p.event.SwarmIPv6PublicAddresses = publicIP6Addrs +} + +func (p *telemetryPlugin) collectAutoTLSInfo() { + p.event.AutoTLSAutoWSS = p.config.AutoTLS.AutoWSS.WithDefault(config.DefaultAutoWSS) + domainSuffix := p.config.AutoTLS.DomainSuffix.WithDefault(config.DefaultDomainSuffix) + p.event.AutoTLSDomainSuffixCustom = domainSuffix != config.DefaultDomainSuffix +} + +func (p *telemetryPlugin) collectDiscoveryInfo() { + p.event.DiscoveryMDNSEnabled = p.config.Discovery.MDNS.Enabled +} + +func (p *telemetryPlugin) collectPlatformInfo() { + p.event.PlatformOS = runtime.GOOS + p.event.PlatformArch = runtime.GOARCH + p.event.PlatformContainerized = isRunningInContainer() + p.event.PlatformVM = isRunningInVM() +} + +func isRunningInContainer() bool { + // Check for Docker container + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + + // Check cgroup for container + content, err := os.ReadFile("/proc/self/cgroup") + if err == nil { + if strings.Contains(string(content), "docker") || strings.Contains(string(content), "lxc") || strings.Contains(string(content), "/kubepods") { + return true + } + } + + content, err = os.ReadFile("/proc/self/mountinfo") + if err == nil { + for line := range strings.Lines(string(content)) { + if strings.Contains(line, "overlay") && strings.Contains(line, "/var/lib/containers/storage/overlay") { + return true + } + } + } + + // Also check for systemd-nspawn + if _, err := os.Stat("/run/systemd/container"); err == nil { + return true + } + + return false +} + +func isRunningInVM() bool { + // Check for VM + if _, err := os.Stat("/sys/hypervisor/uuid"); err == nil { + return true + } + + // Check for other VM indicators + if _, err := os.Stat("/dev/virt-0"); err == nil { + return true + } + + return false +} + +func (p *telemetryPlugin) sendTelemetry() error { + data, err := json.MarshalIndent(p.event, "", " ") + if err != nil { + return err + } + + log.Debugf("sending telemetry:\n %s", data) + + req, err := http.NewRequest("POST", p.endpoint, bytes.NewBuffer(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", ipfs.GetUserAgentVersion()) + req.Close = true + + // Use client with timeout to prevent hanging + client := &http.Client{ + Timeout: httpTimeout, + } + resp, err := client.Do(req) + if err != nil { + log.Debugf("failed to send telemetry: %s", err) + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + err := fmt.Errorf("telemetry endpoint returned HTTP %d", resp.StatusCode) + log.Debug(err) + return err + } + log.Debugf("telemetry sent successfully (%d)", resp.StatusCode) + return nil +} diff --git a/plugin/plugins/telemetry/telemetry_test.go b/plugin/plugins/telemetry/telemetry_test.go new file mode 100644 index 000000000..6b88ced92 --- /dev/null +++ b/plugin/plugins/telemetry/telemetry_test.go @@ -0,0 +1,170 @@ +package telemetry + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/cockroachdb/pebble/v2" + logging "github.com/ipfs/go-log/v2" + "github.com/ipfs/kubo/config" + "github.com/ipfs/kubo/core" + "github.com/ipfs/kubo/core/node/libp2p" + "github.com/ipfs/kubo/plugin" + "github.com/ipfs/kubo/plugin/plugins/pebbleds" + "github.com/ipfs/kubo/repo/fsrepo" +) + +func mockServer(t *testing.T) (*httptest.Server, func() LogEvent) { + t.Helper() + + var e LogEvent + + // Create a mock HTTP test server + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is POST to the correct endpoint + if r.Method != "POST" || r.URL.Path != "/" { + t.Log("invalid request") + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + // Check content type + if r.Header.Get("Content-Type") != "application/json" { + t.Log("invalid content type") + http.Error(w, "invalid content type", http.StatusBadRequest) + return + } + + // Check if the body is not empty + if r.Body == nil { + t.Log("empty body") + http.Error(w, "empty body", http.StatusBadRequest) + return + } + + // Read the body + body, _ := io.ReadAll(r.Body) + if len(body) == 0 { + t.Log("zero-length body") + http.Error(w, "empty body", http.StatusBadRequest) + return + } + + t.Logf("Received telemetry:\n %s", string(body)) + + err := json.Unmarshal(body, &e) + if err != nil { + t.Log("error unmarshaling event", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Return success + w.WriteHeader(http.StatusOK) + })), func() LogEvent { return e } +} + +func makeNode(t *testing.T) (node *core.IpfsNode, repopath string) { + t.Helper() + + // Create a Temporary Repo + repoPath, err := os.MkdirTemp("", "ipfs-shell") + if err != nil { + t.Fatal(err) + } + + pebbledspli := pebbleds.Plugins[0] + pebbledspl, ok := pebbledspli.(plugin.PluginDatastore) + if !ok { + t.Fatal("bad datastore plugin") + } + + err = fsrepo.AddDatastoreConfigHandler(pebbledspl.DatastoreTypeName(), pebbledspl.DatastoreConfigParser()) + if err != nil { + t.Fatal(err) + } + + // Create a config with default options and a 2048 bit key + cfg, err := config.Init(io.Discard, 2048) + if err != nil { + t.Fatal(err) + } + + cfg.Datastore.Spec = map[string]interface{}{ + "type": "pebbleds", + "prefix": "pebble.datastore", + "path": "pebbleds", + "formatMajorVersion": int(pebble.FormatNewest), + } + + // Create the repo with the config + err = fsrepo.Init(repoPath, cfg) + if err != nil { + t.Fatal(err) + } + + // Open the repo + repo, err := fsrepo.Open(repoPath) + if err != nil { + t.Fatal(err) + } + + // Construct the node + + nodeOptions := &core.BuildCfg{ + Online: true, + Routing: libp2p.NilRouterOption, + Repo: repo, + } + + node, err = core.NewNode(context.Background(), nodeOptions) + if err != nil { + t.Fatal(err) + } + + node.IsDaemon = true + return +} + +func TestSendTelemetry(t *testing.T) { + if err := logging.SetLogLevel("telemetry", "DEBUG"); err != nil { + t.Fatal(err) + } + ts, eventGetter := mockServer(t) + defer ts.Close() + + node, repoPath := makeNode(t) + + // Create a plugin instance + p := &telemetryPlugin{ + runOnce: true, + } + + // Initialize the plugin + pe := &plugin.Environment{ + Repo: repoPath, + Config: nil, + } + err := p.Init(pe) + if err != nil { + t.Fatalf("Init() failed: %v", err) + } + + p.endpoint = ts.URL + + // Start the plugin + err = p.Start(node) + if err != nil { + t.Fatalf("Start() failed: %v", err) + } + + e := eventGetter() + if e.UUID != p.event.UUID { + t.Fatal("uuid mismatch") + } +} diff --git a/plugin/plugins/telemetry/telemetry_uuid b/plugin/plugins/telemetry/telemetry_uuid new file mode 100644 index 000000000..f80cb9c3f --- /dev/null +++ b/plugin/plugins/telemetry/telemetry_uuid @@ -0,0 +1 @@ +289ffed8-c770-49ae-922f-b020c8f776f2 \ No newline at end of file diff --git a/test/cli/telemetry_test.go b/test/cli/telemetry_test.go new file mode 100644 index 000000000..51c23414b --- /dev/null +++ b/test/cli/telemetry_test.go @@ -0,0 +1,184 @@ +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") + }) +}