@cryptotaxi247 / kubo / commits / 4255cc388

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>

Hector Sanjuan committed Aug 18, 2025 at 20:46 UTC 4255cc38894b393387319ef23d2a89a39c9063f8
11 files changed +1108 -2
.cspell.yml
+1 -1
@@ -3,4 +3,4 @@ ignoreWords:
3 - NodeCreater # This spelling is used in the fuse dependency
4 - Boddy # One of the contributors to the project - Chris Boddy
5 - Botto # One of the contributors to the project - Santiago Botto
6 - - cose # dag-cose
6 + - cose # dag-cose
\ No newline at end of file
docs/changelogs/v0.37.md
+49 -1
@@ -20,6 +20,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
20 - [Improved `ipfs cid`](#improved-ipfs-cid)
21 - [Deprecated `ipfs stats reprovide`](#deprecated-ipfs-stats-reprovide)
22 - [🔄 AutoRelay now uses all connected peers for relay discovery](#-autorelay-now-uses-all-connected-peers-for-relay-discovery)
23 + - [📊 Anonymous telemetry for better feature prioritization](#-anonymous-telemetry-for-better-feature-prioritization)
24 - [📦️ Important dependency updates](#-important-dependency-updates)
25 - [📝 Changelog](#-changelog)
26 - [👨‍👩‍👧‍👦 Contributors](#-contributors)
@@ -136,7 +137,54 @@ The `ipfs stats reprovide` command has moved to `ipfs provide stat`. This was do
137
138 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.
139
139 -#### 📦️ Important dependency updates
140 +#### 📊 Anonymous telemetry for better feature prioritization
141 +
142 +Per a suggestion from the IPFS Foundation, Kubo now sends optional anonymized telemetry information to Shipyard [maintainers](https://github.com/ipshipyard/roadmaps/issues/20).
143 +
144 +**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.
145 +
146 +**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.
147 +
148 +**What**: Currently, we send the following anonymous metrics:
149 +
150 +```
151 + "uuid": "<unique_uuid>",
152 + "agent_version": "kubo/0.37.0-dev",
153 + "private_network": false,
154 + "bootstrappers_custom": false,
155 + "repo_size_bucket": 1073741824,
156 + "uptime_bucket": 86400000000000,
157 + "reprovider_strategy": "pinned",
158 + "routing_type": "auto",
159 + "routing_accelerated_dht_client": false,
160 + "routing_delegated_count": 0,
161 + "autonat_service_mode": "enabled",
162 + "autonat_reachability": "",
163 + "swarm_enable_hole_punching": true,
164 + "swarm_circuit_addresses": false,
165 + "swarm_ipv4_public_addresses": true,
166 + "swarm_ipv6_public_addresses": true,
167 + "auto_tls_auto_wss": true,
168 + "auto_tls_domain_suffix_custom": false,
169 + "discovery_mdns_enabled": true,
170 + "platform_os": "linux",
171 + "platform_arch": "amd64",
172 + "platform_containerized": false,
173 + "platform_vm": false
174 +```
175 +
176 +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.
177 +
178 +**User control**: You can opt-out at any time:
179 +
180 +- Set environment variable `IPFS_TELEMETRY=off` before starting the daemon
181 +- Or run `ipfs config Plugins.Plugins.telemetry.Config.Mode off` and restart the daemon
182 +
183 +The telemetry plugin code lives in `plugin/plugins/telemetry`.
184 +
185 +Learn more: [`/kubo/docs/telemetry.md`](https://github.com/ipfs/kubo/blob/master/docs/telemetry.md)
186 +
187 +### 📦️ Important dependency updates
188
189 - 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))
190 - update `p2p-forge/client` to [v0.6.1](https://github.com/ipshipyard/p2p-forge/releases/tag/v0.6.1)
docs/environment-variables.md
+17
@@ -18,6 +18,7 @@
18 - [`IPFS_HTTP_ROUTERS_FILTER_PROTOCOLS`](#ipfs_http_routers_filter_protocols)
19 - [`IPFS_CONTENT_BLOCKING_DISABLE`](#ipfs_content_blocking_disable)
20 - [`IPFS_WAIT_REPO_LOCK`](#ipfs_wait_repo_lock)
21 + - [`IPFS_TELEMETRY`](#ipfs_telemetry)
22 - [`LIBP2P_TCP_REUSEPORT`](#libp2p_tcp_reuseport)
23 - [`LIBP2P_TCP_MUX`](#libp2p_tcp_mux)
24 - [`LIBP2P_MUX_PREFS`](#libp2p_mux_prefs)
@@ -194,6 +195,22 @@ IPFS_WAIT_REPO_LOCK="15s"
195
196 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.
197
198 +## `IPFS_TELEMETRY`
199 +
200 +Controls the behavior of the [telemetry plugin](telemetry.md). Valid values are:
201 +
202 +- `on`: Enables telemetry.
203 +- `off`: Disables telemetry.
204 +- `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.
205 +
206 +The mode can also be set in the config file under `Plugins.Plugins.telemetry.Config.Mode`.
207 +
208 +Example:
209 +
210 +```bash
211 +export IPFS_TELEMETRY="off"
212 +```
213 +
214 ## `LIBP2P_TCP_REUSEPORT`
215
216 Kubo tries to reuse the same source port for all connections to improve NAT
docs/plugins.md
+1
@@ -117,6 +117,7 @@ Example:
117 | [flatfs](https://github.com/ipfs/kubo/tree/master/plugin/plugins/flatfs) | Datastore | x | A stable filesystem-based datastore. |
118 | [levelds](https://github.com/ipfs/kubo/tree/master/plugin/plugins/levelds) | Datastore | x | A stable, flexible datastore backend. |
119 | [jaeger](https://github.com/ipfs/go-jaeger-plugin) | Tracing | | An opentracing backend. |
120 +| [telemetry](https://github.com/ipfs/kubo/tree/master/plugin/plugins/telemetry) | Telemetry | x | Collects anonymized usage data for Kubo development. |
121
122 * **Preloaded** plugins are built into the Kubo binary and do not need to be
123 installed separately. At the moment, all in-tree plugins are preloaded.
docs/telemetry.md new
+122
@@ -0,0 +1,122 @@
1 +# Telemetry Plugin Documentation
2 +
3 +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.
4 +
5 +This data is not personally identifiable and is used solely for the purpose of improving the Kubo project.
6 +
7 +---
8 +
9 +## 🛡️ How to Control Telemetry
10 +
11 +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.
12 +
13 +### Available Modes
14 +
15 +| Mode | Description |
16 +|----------|-----------------------------------------------------------------------------|
17 +| `on` | **Default**. Telemetry is enabled. Data is sent periodically. |
18 +| `off` | Telemetry is disabled. No data is sent. Any existing telemetry UUID file is removed. |
19 +| `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. |
20 +
21 +You can set the mode in your environment:
22 +
23 +```bash
24 +export IPFS_TELEMETRY="off"
25 +```
26 +
27 +Or in your IPFS config file:
28 +
29 +```json
30 +{
31 + "Plugins": {
32 + "Plugins": {
33 + "telemetry": {
34 + "Config": {
35 + "Mode": "off"
36 + }
37 + }
38 + }
39 + }
40 +}
41 +```
42 +
43 +---
44 +
45 +## 📦 What Data is Collected?
46 +
47 +The telemetry plugin collects the following anonymized data:
48 +
49 +### General Information
50 +- **Agent version**: The version of Kubo being used.
51 +- **Platform details**: Operating system, architecture, and container status.
52 +- **Uptime**: How long the node has been running, categorized into buckets.
53 +- **Repo size**: Categorized into buckets (e.g., 1GB, 5GB, 10GB, etc.).
54 +
55 +### Network Configuration
56 +- **Private network**: Whether the node is running in a private network.
57 +- **Bootstrap peers**: Whether custom bootstrap peers are used.
58 +- **Routing type**: Whether the node uses DHT, IPFS, or a custom routing setup.
59 +- **AutoNAT settings**: Whether AutoNAT is enabled and its reachability status.
60 +- **Swarm settings**: Whether hole punching is enabled, and whether public IP addresses are used.
61 +
62 +### TLS and Discovery
63 +- **AutoTLS settings**: Whether WSS is enabled and whether a custom domain suffix is used.
64 +- **Discovery settings**: Whether mDNS is enabled.
65 +
66 +### Reprovider Strategy
67 +- The strategy used for reprovider (e.g., "all", "pinned"...).
68 +
69 +---
70 +
71 +## 🧑‍🤝‍🧑 Privacy and Anonymization
72 +
73 +All data collected is:
74 +- **Anonymized**: No personally identifiable information (PII) is sent.
75 +- **Optional**: Users can choose to opt out at any time.
76 +- **Secure**: Data is sent over HTTPS to a trusted endpoint.
77 +
78 +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.
79 +
80 +---
81 +
82 +## 📦 Contributing to the Project
83 +
84 +By enabling telemetry, you are helping the Kubo team improve the software for the entire community. The data is used to:
85 +
86 +- Prioritize feature development
87 +- Identify performance bottlenecks
88 +- Improve user experience
89 +
90 +You can always disable telemetry at any time if you change your mind.
91 +
92 +---
93 +
94 +## 🧪 Testing Telemetry
95 +
96 +If you're testing telemetry locally, you can change the endpoint by setting the `Endpoint` field in the config:
97 +
98 +```json
99 +{
100 + "Plugins": {
101 + "Plugins": {
102 + "telemetry": {
103 + "Config": {
104 + "Mode": "on",
105 + "Endpoint": "http://localhost:8080"
106 + }
107 + }
108 + }
109 + }
110 +}
111 +```
112 +
113 +This allows you to capture and inspect telemetry data locally.
114 +
115 +---
116 +
117 +## 📦 Further Reading
118 +
119 +For more information, see:
120 +- [IPFS Environment Variables](docs/environment-variables.md)
121 +- [IPFS Plugins](docs/plugins.md)
122 +- [IPFS Configuration](docs/config.md)
plugin/loader/preload.go
+2
@@ -10,6 +10,7 @@ import (
10 pluginnopfs "github.com/ipfs/kubo/plugin/plugins/nopfs"
11 pluginpebbleds "github.com/ipfs/kubo/plugin/plugins/pebbleds"
12 pluginpeerlog "github.com/ipfs/kubo/plugin/plugins/peerlog"
13 + plugintelemetry "github.com/ipfs/kubo/plugin/plugins/telemetry"
14 )
15
16 // DO NOT EDIT THIS FILE
@@ -26,4 +27,5 @@ func init() {
27 Preload(pluginpeerlog.Plugins...)
28 Preload(pluginfxtest.Plugins...)
29 Preload(pluginnopfs.Plugins...)
30 + Preload(plugintelemetry.Plugins...)
31 }
plugin/loader/preload_list
+1
@@ -13,3 +13,4 @@ pebbleds github.com/ipfs/kubo/plugin/plugins/pebbleds *
13 peerlog github.com/ipfs/kubo/plugin/plugins/peerlog *
14 fxtest github.com/ipfs/kubo/plugin/plugins/fxtest *
15 nopfs github.com/ipfs/kubo/plugin/plugins/nopfs *
16 +telemetry github.com/ipfs/kubo/plugin/plugins/telemetry *
plugin/plugins/telemetry/telemetry.go new
+560
@@ -0,0 +1,560 @@
1 +package telemetry
2 +
3 +import (
4 + "bytes"
5 + "context"
6 + "encoding/json"
7 + "fmt"
8 + "net/http"
9 + "os"
10 + "path"
11 + "runtime"
12 + "strings"
13 + "time"
14 +
15 + "github.com/google/uuid"
16 + logging "github.com/ipfs/go-log/v2"
17 + ipfs "github.com/ipfs/kubo"
18 + "github.com/ipfs/kubo/config"
19 + "github.com/ipfs/kubo/core"
20 + "github.com/ipfs/kubo/core/corerepo"
21 + "github.com/ipfs/kubo/plugin"
22 + "github.com/libp2p/go-libp2p/core/network"
23 + "github.com/libp2p/go-libp2p/core/pnet"
24 + multiaddr "github.com/multiformats/go-multiaddr"
25 + manet "github.com/multiformats/go-multiaddr/net"
26 +)
27 +
28 +var log = logging.Logger("telemetry")
29 +
30 +const (
31 + modeEnvVar = "IPFS_TELEMETRY"
32 + uuidFilename = "telemetry_uuid"
33 + endpoint = "https://telemetry.ipshipyard.dev"
34 + sendDelay = 15 * time.Minute // delay before first telemetry collection after daemon start
35 + sendInterval = 24 * time.Hour // interval between telemetry collections after the first one
36 + httpTimeout = 30 * time.Second // timeout for telemetry HTTP requests
37 +)
38 +
39 +type pluginMode int
40 +
41 +const (
42 + modeAuto pluginMode = iota
43 + modeOn
44 + modeOff
45 +)
46 +
47 +// repoSizeBuckets defines size thresholds for categorizing repository sizes.
48 +// Each value represents the upper limit of a bucket in bytes (except the last)
49 +var repoSizeBuckets = []uint64{
50 + 1 << 30, // 1 GB
51 + 5 << 30, // 5 GB
52 + 10 << 30, // 10 GB
53 + 100 << 30, // 100 GB
54 + 500 << 30, // 500 GB
55 + 1 << 40, // 1 TB
56 + 10 << 40, // 10 TB
57 + 11 << 40, // + anything more than 10TB falls here.
58 +}
59 +
60 +var uptimeBuckets = []time.Duration{
61 + 1 * 24 * time.Hour,
62 + 2 * 24 * time.Hour,
63 + 3 * 24 * time.Hour,
64 + 7 * 24 * time.Hour,
65 + 14 * 24 * time.Hour,
66 + 30 * 24 * time.Hour,
67 + 31 * 24 * time.Hour, // + anything more than 30 days falls here.
68 +}
69 +
70 +// A LogEvent is the object sent to the telemetry endpoint.
71 +type LogEvent struct {
72 + UUID string `json:"uuid"`
73 +
74 + AgentVersion string `json:"agent_version"`
75 +
76 + PrivateNetwork bool `json:"private_network"`
77 +
78 + BootstrappersCustom bool `json:"bootstrappers_custom"`
79 +
80 + RepoSizeBucket uint64 `json:"repo_size_bucket"`
81 +
82 + UptimeBucket time.Duration `json:"uptime_bucket"`
83 +
84 + ReproviderStrategy string `json:"reprovider_strategy"`
85 +
86 + RoutingType string `json:"routing_type"`
87 + RoutingAcceleratedDHTClient bool `json:"routing_accelerated_dht_client"`
88 + RoutingDelegatedCount int `json:"routing_delegated_count"`
89 +
90 + AutoNATServiceMode string `json:"autonat_service_mode"`
91 + AutoNATReachability string `json:"autonat_reachability"`
92 +
93 + SwarmEnableHolePunching bool `json:"swarm_enable_hole_punching"`
94 + SwarmCircuitAddresses bool `json:"swarm_circuit_addresses"`
95 + SwarmIPv4PublicAddresses bool `json:"swarm_ipv4_public_addresses"`
96 + SwarmIPv6PublicAddresses bool `json:"swarm_ipv6_public_addresses"`
97 +
98 + AutoTLSAutoWSS bool `json:"auto_tls_auto_wss"`
99 + AutoTLSDomainSuffixCustom bool `json:"auto_tls_domain_suffix_custom"`
100 +
101 + DiscoveryMDNSEnabled bool `json:"discovery_mdns_enabled"`
102 +
103 + PlatformOS string `json:"platform_os"`
104 + PlatformArch string `json:"platform_arch"`
105 + PlatformContainerized bool `json:"platform_containerized"`
106 + PlatformVM bool `json:"platform_vm"`
107 +}
108 +
109 +var Plugins = []plugin.Plugin{
110 + &telemetryPlugin{},
111 +}
112 +
113 +type telemetryPlugin struct {
114 + uuidFilename string
115 + mode pluginMode
116 + endpoint string
117 + runOnce bool // test-only flag: when true, sends telemetry immediately without delay
118 + sendDelay time.Duration
119 +
120 + node *core.IpfsNode
121 + config *config.Config
122 + event *LogEvent
123 + startTime time.Time
124 +}
125 +
126 +func (p *telemetryPlugin) Name() string {
127 + return "telemetry"
128 +}
129 +
130 +func (p *telemetryPlugin) Version() string {
131 + return "0.0.1"
132 +}
133 +
134 +func readFromConfig(cfg interface{}, key string) string {
135 + if cfg == nil {
136 + return ""
137 + }
138 +
139 + pcfg, ok := cfg.(map[string]interface{})
140 + if !ok {
141 + return ""
142 + }
143 +
144 + val, ok := pcfg[key].(string)
145 + if !ok {
146 + return ""
147 + }
148 + return val
149 +}
150 +
151 +func (p *telemetryPlugin) Init(env *plugin.Environment) error {
152 + // logging.SetLogLevel("telemetry", "DEBUG")
153 + log.Debug("telemetry plugin Init()")
154 + p.event = &LogEvent{}
155 + p.startTime = time.Now()
156 +
157 + repoPath := env.Repo
158 + p.uuidFilename = path.Join(repoPath, uuidFilename)
159 +
160 + v := os.Getenv(modeEnvVar)
161 + if v != "" {
162 + log.Debug("mode set from env-var")
163 + } else if pmode := readFromConfig(env.Config, "Mode"); pmode != "" {
164 + v = pmode
165 + log.Debug("mode set from config")
166 + }
167 +
168 + // read "Delay" from the config. Parse as duration. Set p.sendDelay to it
169 + // or set default.
170 + if delayStr := readFromConfig(env.Config, "Delay"); delayStr != "" {
171 + delay, err := time.ParseDuration(delayStr)
172 + if err != nil {
173 + log.Debug("sendDelay set from default")
174 + p.sendDelay = sendDelay
175 + } else {
176 + log.Debug("sendDelay set from config")
177 + p.sendDelay = delay
178 + }
179 + } else {
180 + log.Debug("sendDelay set from default")
181 + p.sendDelay = sendDelay
182 + }
183 +
184 + p.endpoint = endpoint
185 + if ep := readFromConfig(env.Config, "Endpoint"); ep != "" {
186 + log.Debug("endpoint set from config", ep)
187 + p.endpoint = ep
188 + }
189 +
190 + switch v {
191 + case "off":
192 + p.mode = modeOff
193 + log.Debug("telemetry disabled via opt-out")
194 + // Remove UUID file if it exists when user opts out
195 + if _, err := os.Stat(p.uuidFilename); err == nil {
196 + if err := os.Remove(p.uuidFilename); err != nil {
197 + log.Debugf("failed to remove telemetry UUID file: %s", err)
198 + } else {
199 + log.Debug("removed existing telemetry UUID file due to opt-out")
200 + }
201 + }
202 + return nil
203 + case "auto":
204 + p.mode = modeAuto
205 + default:
206 + p.mode = modeOn
207 + }
208 + log.Debug("telemetry mode: ", p.mode)
209 + return nil
210 +}
211 +
212 +func (p *telemetryPlugin) loadUUID() error {
213 + // Generate or read our UUID from disk
214 + b, err := os.ReadFile(p.uuidFilename)
215 + if err != nil {
216 + if !os.IsNotExist(err) {
217 + log.Errorf("error reading telemetry uuid from disk: %s", err)
218 + return err
219 + }
220 + uid, err := uuid.NewRandom()
221 + if err != nil {
222 + log.Errorf("cannot generate telemetry uuid: %s", err)
223 + return err
224 + }
225 + p.event.UUID = uid.String()
226 + p.mode = modeAuto
227 + log.Debugf("new telemetry UUID %s. Mode set to Auto", uid)
228 +
229 + // Write the UUID to disk
230 + if err := os.WriteFile(p.uuidFilename, []byte(p.event.UUID), 0600); err != nil {
231 + log.Errorf("cannot write telemetry uuid: %s", err)
232 + return err
233 + }
234 + return nil
235 + }
236 +
237 + v := string(b)
238 + v = strings.TrimSpace(v)
239 + uid, err := uuid.Parse(v)
240 + if err != nil {
241 + log.Errorf("cannot parse telemetry uuid: %s", err)
242 + return err
243 + }
244 + log.Debugf("uuid read from disk %s", uid)
245 + p.event.UUID = uid.String()
246 + return nil
247 +}
248 +
249 +func (p *telemetryPlugin) hasDefaultBootstrapPeers() bool {
250 + defaultPeers := config.DefaultBootstrapAddresses
251 + currentPeers := p.config.Bootstrap
252 + if len(defaultPeers) != len(currentPeers) {
253 + return false
254 + }
255 + peerMap := make(map[string]struct{}, len(defaultPeers))
256 + for _, peer := range defaultPeers {
257 + peerMap[peer] = struct{}{}
258 + }
259 + for _, peer := range currentPeers {
260 + if _, ok := peerMap[peer]; !ok {
261 + return false
262 + }
263 + }
264 + return true
265 +}
266 +
267 +func (p *telemetryPlugin) showInfo() {
268 + fmt.Printf(`
269 +
270 +ℹ️ Anonymous telemetry will be enabled in %s
271 +
272 +Kubo will collect anonymous usage data to help improve the software:
273 +• What: Feature usage and configuration (no personal data)
274 + Use GOLOG_LOG_LEVEL="telemetry=debug" to inspect collected data
275 +• When: First collection in %s, then every 24h
276 +• How: HTTP POST to %s
277 + Anonymous ID: %s
278 +
279 +No data sent yet. To opt-out before collection starts:
280 +• Set environment: %s=off
281 +• Or run: ipfs config Plugins.Plugins.telemetry.Config.Mode off
282 +• Then restart daemon
283 +
284 +This message is shown only once.
285 +Learn more: https://github.com/ipfs/kubo/blob/master/docs/telemetry.md
286 +
287 +
288 +`, p.sendDelay, p.sendDelay, endpoint, p.event.UUID, modeEnvVar)
289 +}
290 +
291 +// Start finishes telemetry initialization once the IpfsNode is ready,
292 +// collects telemetry data and sends it to the endpoint.
293 +func (p *telemetryPlugin) Start(n *core.IpfsNode) error {
294 + // We should not be crashing the daemon due to problems with telemetry
295 + // so this is always going to return nil and panics are going to be
296 + // handled.
297 + defer func() {
298 + if r := recover(); r != nil {
299 + log.Errorf("telemetry plugin panicked: %v", r)
300 + }
301 + }()
302 +
303 + p.node = n
304 + cfg, err := n.Repo.Config()
305 + if err != nil {
306 + log.Error("error getting the repo.Config: %s", err)
307 + return nil
308 + }
309 + p.config = cfg
310 + if p.mode == modeOff {
311 + log.Debug("telemetry collection skipped: opted out")
312 + return nil
313 + }
314 +
315 + if !n.IsDaemon || !n.IsOnline {
316 + log.Debugf("skipping telemetry. Daemon: %t. Online: %t", n.IsDaemon, n.IsOnline)
317 + return nil
318 + }
319 +
320 + // loadUUID might switch to modeAuto when generating a new uuid
321 + if err := p.loadUUID(); err != nil {
322 + p.mode = modeOff
323 + return nil
324 + }
325 +
326 + if p.mode == modeAuto {
327 + p.showInfo()
328 + }
329 +
330 + // runOnce is only used in tests to send telemetry immediately.
331 + // In production, this is always false, ensuring users get the 15-minute delay.
332 + if p.runOnce {
333 + p.prepareEvent()
334 + return p.sendTelemetry()
335 + }
336 +
337 + go func() {
338 + timer := time.NewTimer(p.sendDelay)
339 + for range timer.C {
340 + p.prepareEvent()
341 + if err := p.sendTelemetry(); err != nil {
342 + log.Warnf("telemetry submission failed: %s (will retry in %s)", err, sendInterval)
343 + }
344 + timer.Reset(sendInterval)
345 + }
346 + }()
347 +
348 + return nil
349 +}
350 +
351 +func (p *telemetryPlugin) prepareEvent() {
352 + p.collectBasicInfo()
353 + p.collectRoutingInfo()
354 + p.collectAutoNATInfo()
355 + p.collectSwarmInfo()
356 + p.collectAutoTLSInfo()
357 + p.collectDiscoveryInfo()
358 + p.collectPlatformInfo()
359 +}
360 +
361 +// Collects:
362 +// * AgentVersion
363 +// * PrivateNetwork
364 +// * RepoSizeBucket
365 +// * BootstrappersCustom
366 +// * UptimeBucket
367 +// * ReproviderStrategy
368 +func (p *telemetryPlugin) collectBasicInfo() {
369 + p.event.AgentVersion = ipfs.GetUserAgentVersion()
370 +
371 + privNet := false
372 + if pnet.ForcePrivateNetwork {
373 + privNet = true
374 + } else if key, _ := p.node.Repo.SwarmKey(); key != nil {
375 + privNet = true
376 + }
377 + p.event.PrivateNetwork = privNet
378 +
379 + p.event.BootstrappersCustom = !p.hasDefaultBootstrapPeers()
380 +
381 + repoSizeBucket := repoSizeBuckets[len(repoSizeBuckets)-1]
382 + sizeStat, err := corerepo.RepoSize(context.Background(), p.node)
383 + if err == nil {
384 + for _, b := range repoSizeBuckets {
385 + if sizeStat.RepoSize > b {
386 + continue
387 + }
388 + repoSizeBucket = b
389 + break
390 + }
391 + p.event.RepoSizeBucket = repoSizeBucket
392 + } else {
393 + log.Debugf("error setting sizeStat: %s", err)
394 + }
395 +
396 + uptime := time.Since(p.startTime)
397 + uptimeBucket := uptimeBuckets[len(uptimeBuckets)-1]
398 + for _, bucket := range uptimeBuckets {
399 + if uptime > bucket {
400 + continue
401 +
402 + }
403 + uptimeBucket = bucket
404 + break
405 + }
406 + p.event.UptimeBucket = uptimeBucket
407 +
408 + p.event.ReproviderStrategy = p.config.Reprovider.Strategy.WithDefault(config.DefaultReproviderStrategy)
409 +}
410 +
411 +func (p *telemetryPlugin) collectRoutingInfo() {
412 + p.event.RoutingType = p.config.Routing.Type.WithDefault("auto")
413 + p.event.RoutingAcceleratedDHTClient = p.config.Routing.AcceleratedDHTClient.WithDefault(false)
414 + p.event.RoutingDelegatedCount = len(p.config.Routing.DelegatedRouters)
415 +}
416 +
417 +type reachabilityHost interface {
418 + Reachability() network.Reachability
419 +}
420 +
421 +func (p *telemetryPlugin) collectAutoNATInfo() {
422 + autonat := p.config.AutoNAT.ServiceMode
423 + if autonat == config.AutoNATServiceUnset {
424 + autonat = config.AutoNATServiceEnabled
425 + }
426 + autoNATSvcModeB, err := autonat.MarshalText()
427 + if err == nil {
428 + autoNATSvcMode := string(autoNATSvcModeB)
429 + if autoNATSvcMode == "" {
430 + autoNATSvcMode = "unset"
431 + }
432 + p.event.AutoNATServiceMode = autoNATSvcMode
433 + }
434 +
435 + h := p.node.PeerHost
436 + reachHost, ok := h.(reachabilityHost)
437 + if ok {
438 + p.event.AutoNATReachability = reachHost.Reachability().String()
439 + }
440 +}
441 +
442 +func (p *telemetryPlugin) collectSwarmInfo() {
443 + p.event.SwarmEnableHolePunching = p.config.Swarm.EnableHolePunching.WithDefault(true)
444 +
445 + var circuitAddrs, publicIP4Addrs, publicIP6Addrs bool
446 + for _, addr := range p.node.PeerHost.Addrs() {
447 + if manet.IsPublicAddr(addr) {
448 + if _, err := addr.ValueForProtocol(multiaddr.P_IP4); err == nil {
449 + publicIP4Addrs = true
450 + } else if _, err := addr.ValueForProtocol(multiaddr.P_IP6); err == nil {
451 + publicIP6Addrs = true
452 + }
453 + }
454 + if _, err := addr.ValueForProtocol(multiaddr.P_CIRCUIT); err == nil {
455 + circuitAddrs = true
456 + }
457 + }
458 +
459 + p.event.SwarmCircuitAddresses = circuitAddrs
460 + p.event.SwarmIPv4PublicAddresses = publicIP4Addrs
461 + p.event.SwarmIPv6PublicAddresses = publicIP6Addrs
462 +}
463 +
464 +func (p *telemetryPlugin) collectAutoTLSInfo() {
465 + p.event.AutoTLSAutoWSS = p.config.AutoTLS.AutoWSS.WithDefault(config.DefaultAutoWSS)
466 + domainSuffix := p.config.AutoTLS.DomainSuffix.WithDefault(config.DefaultDomainSuffix)
467 + p.event.AutoTLSDomainSuffixCustom = domainSuffix != config.DefaultDomainSuffix
468 +}
469 +
470 +func (p *telemetryPlugin) collectDiscoveryInfo() {
471 + p.event.DiscoveryMDNSEnabled = p.config.Discovery.MDNS.Enabled
472 +}
473 +
474 +func (p *telemetryPlugin) collectPlatformInfo() {
475 + p.event.PlatformOS = runtime.GOOS
476 + p.event.PlatformArch = runtime.GOARCH
477 + p.event.PlatformContainerized = isRunningInContainer()
478 + p.event.PlatformVM = isRunningInVM()
479 +}
480 +
481 +func isRunningInContainer() bool {
482 + // Check for Docker container
483 + if _, err := os.Stat("/.dockerenv"); err == nil {
484 + return true
485 + }
486 +
487 + // Check cgroup for container
488 + content, err := os.ReadFile("/proc/self/cgroup")
489 + if err == nil {
490 + if strings.Contains(string(content), "docker") || strings.Contains(string(content), "lxc") || strings.Contains(string(content), "/kubepods") {
491 + return true
492 + }
493 + }
494 +
495 + content, err = os.ReadFile("/proc/self/mountinfo")
496 + if err == nil {
497 + for line := range strings.Lines(string(content)) {
498 + if strings.Contains(line, "overlay") && strings.Contains(line, "/var/lib/containers/storage/overlay") {
499 + return true
500 + }
501 + }
502 + }
503 +
504 + // Also check for systemd-nspawn
505 + if _, err := os.Stat("/run/systemd/container"); err == nil {
506 + return true
507 + }
508 +
509 + return false
510 +}
511 +
512 +func isRunningInVM() bool {
513 + // Check for VM
514 + if _, err := os.Stat("/sys/hypervisor/uuid"); err == nil {
515 + return true
516 + }
517 +
518 + // Check for other VM indicators
519 + if _, err := os.Stat("/dev/virt-0"); err == nil {
520 + return true
521 + }
522 +
523 + return false
524 +}
525 +
526 +func (p *telemetryPlugin) sendTelemetry() error {
527 + data, err := json.MarshalIndent(p.event, "", " ")
528 + if err != nil {
529 + return err
530 + }
531 +
532 + log.Debugf("sending telemetry:\n %s", data)
533 +
534 + req, err := http.NewRequest("POST", p.endpoint, bytes.NewBuffer(data))
535 + if err != nil {
536 + return err
537 + }
538 + req.Header.Set("Content-Type", "application/json")
539 + req.Header.Set("User-Agent", ipfs.GetUserAgentVersion())
540 + req.Close = true
541 +
542 + // Use client with timeout to prevent hanging
543 + client := &http.Client{
544 + Timeout: httpTimeout,
545 + }
546 + resp, err := client.Do(req)
547 + if err != nil {
548 + log.Debugf("failed to send telemetry: %s", err)
549 + return err
550 + }
551 + defer resp.Body.Close()
552 +
553 + if resp.StatusCode >= 400 {
554 + err := fmt.Errorf("telemetry endpoint returned HTTP %d", resp.StatusCode)
555 + log.Debug(err)
556 + return err
557 + }
558 + log.Debugf("telemetry sent successfully (%d)", resp.StatusCode)
559 + return nil
560 +}
plugin/plugins/telemetry/telemetry_test.go new
+170
@@ -0,0 +1,170 @@
1 +package telemetry
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "io"
7 + "net/http"
8 + "net/http/httptest"
9 + "os"
10 + "testing"
11 +
12 + "github.com/cockroachdb/pebble/v2"
13 + logging "github.com/ipfs/go-log/v2"
14 + "github.com/ipfs/kubo/config"
15 + "github.com/ipfs/kubo/core"
16 + "github.com/ipfs/kubo/core/node/libp2p"
17 + "github.com/ipfs/kubo/plugin"
18 + "github.com/ipfs/kubo/plugin/plugins/pebbleds"
19 + "github.com/ipfs/kubo/repo/fsrepo"
20 +)
21 +
22 +func mockServer(t *testing.T) (*httptest.Server, func() LogEvent) {
23 + t.Helper()
24 +
25 + var e LogEvent
26 +
27 + // Create a mock HTTP test server
28 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29 + // Check if the request is POST to the correct endpoint
30 + if r.Method != "POST" || r.URL.Path != "/" {
31 + t.Log("invalid request")
32 + http.Error(w, "invalid request", http.StatusBadRequest)
33 + return
34 + }
35 +
36 + // Check content type
37 + if r.Header.Get("Content-Type") != "application/json" {
38 + t.Log("invalid content type")
39 + http.Error(w, "invalid content type", http.StatusBadRequest)
40 + return
41 + }
42 +
43 + // Check if the body is not empty
44 + if r.Body == nil {
45 + t.Log("empty body")
46 + http.Error(w, "empty body", http.StatusBadRequest)
47 + return
48 + }
49 +
50 + // Read the body
51 + body, _ := io.ReadAll(r.Body)
52 + if len(body) == 0 {
53 + t.Log("zero-length body")
54 + http.Error(w, "empty body", http.StatusBadRequest)
55 + return
56 + }
57 +
58 + t.Logf("Received telemetry:\n %s", string(body))
59 +
60 + err := json.Unmarshal(body, &e)
61 + if err != nil {
62 + t.Log("error unmarshaling event", err)
63 + http.Error(w, err.Error(), http.StatusBadRequest)
64 + return
65 + }
66 +
67 + // Return success
68 + w.WriteHeader(http.StatusOK)
69 + })), func() LogEvent { return e }
70 +}
71 +
72 +func makeNode(t *testing.T) (node *core.IpfsNode, repopath string) {
73 + t.Helper()
74 +
75 + // Create a Temporary Repo
76 + repoPath, err := os.MkdirTemp("", "ipfs-shell")
77 + if err != nil {
78 + t.Fatal(err)
79 + }
80 +
81 + pebbledspli := pebbleds.Plugins[0]
82 + pebbledspl, ok := pebbledspli.(plugin.PluginDatastore)
83 + if !ok {
84 + t.Fatal("bad datastore plugin")
85 + }
86 +
87 + err = fsrepo.AddDatastoreConfigHandler(pebbledspl.DatastoreTypeName(), pebbledspl.DatastoreConfigParser())
88 + if err != nil {
89 + t.Fatal(err)
90 + }
91 +
92 + // Create a config with default options and a 2048 bit key
93 + cfg, err := config.Init(io.Discard, 2048)
94 + if err != nil {
95 + t.Fatal(err)
96 + }
97 +
98 + cfg.Datastore.Spec = map[string]interface{}{
99 + "type": "pebbleds",
100 + "prefix": "pebble.datastore",
101 + "path": "pebbleds",
102 + "formatMajorVersion": int(pebble.FormatNewest),
103 + }
104 +
105 + // Create the repo with the config
106 + err = fsrepo.Init(repoPath, cfg)
107 + if err != nil {
108 + t.Fatal(err)
109 + }
110 +
111 + // Open the repo
112 + repo, err := fsrepo.Open(repoPath)
113 + if err != nil {
114 + t.Fatal(err)
115 + }
116 +
117 + // Construct the node
118 +
119 + nodeOptions := &core.BuildCfg{
120 + Online: true,
121 + Routing: libp2p.NilRouterOption,
122 + Repo: repo,
123 + }
124 +
125 + node, err = core.NewNode(context.Background(), nodeOptions)
126 + if err != nil {
127 + t.Fatal(err)
128 + }
129 +
130 + node.IsDaemon = true
131 + return
132 +}
133 +
134 +func TestSendTelemetry(t *testing.T) {
135 + if err := logging.SetLogLevel("telemetry", "DEBUG"); err != nil {
136 + t.Fatal(err)
137 + }
138 + ts, eventGetter := mockServer(t)
139 + defer ts.Close()
140 +
141 + node, repoPath := makeNode(t)
142 +
143 + // Create a plugin instance
144 + p := &telemetryPlugin{
145 + runOnce: true,
146 + }
147 +
148 + // Initialize the plugin
149 + pe := &plugin.Environment{
150 + Repo: repoPath,
151 + Config: nil,
152 + }
153 + err := p.Init(pe)
154 + if err != nil {
155 + t.Fatalf("Init() failed: %v", err)
156 + }
157 +
158 + p.endpoint = ts.URL
159 +
160 + // Start the plugin
161 + err = p.Start(node)
162 + if err != nil {
163 + t.Fatalf("Start() failed: %v", err)
164 + }
165 +
166 + e := eventGetter()
167 + if e.UUID != p.event.UUID {
168 + t.Fatal("uuid mismatch")
169 + }
170 +}
plugin/plugins/telemetry/telemetry_uuid new
+1
@@ -0,0 +1 @@
1 +289ffed8-c770-49ae-922f-b020c8f776f2
\ No newline at end of file
test/cli/telemetry_test.go new
+184
@@ -0,0 +1,184 @@
1 +package cli
2 +
3 +import (
4 + "os"
5 + "path/filepath"
6 + "testing"
7 + "time"
8 +
9 + "github.com/ipfs/kubo/test/cli/harness"
10 + "github.com/stretchr/testify/assert"
11 + "github.com/stretchr/testify/require"
12 +)
13 +
14 +func TestTelemetry(t *testing.T) {
15 + t.Parallel()
16 +
17 + t.Run("opt-out via environment variable", func(t *testing.T) {
18 + t.Parallel()
19 +
20 + // Create a new node
21 + node := harness.NewT(t).NewNode().Init()
22 +
23 + // Set the opt-out environment variable
24 + node.Runner.Env["IPFS_TELEMETRY"] = "off"
25 + node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
26 +
27 + // Capture daemon output
28 + stdout := &harness.Buffer{}
29 + stderr := &harness.Buffer{}
30 +
31 + // Start daemon with output capture
32 + node.StartDaemonWithReq(harness.RunRequest{
33 + CmdOpts: []harness.CmdOpt{
34 + harness.RunWithStdout(stdout),
35 + harness.RunWithStderr(stderr),
36 + },
37 + }, "")
38 +
39 + time.Sleep(500 * time.Millisecond)
40 +
41 + // Get daemon output
42 + output := stdout.String() + stderr.String()
43 +
44 + // Check that telemetry is disabled
45 + assert.Contains(t, output, "telemetry disabled via opt-out", "Expected telemetry disabled message")
46 +
47 + // Stop daemon
48 + node.StopDaemon()
49 +
50 + // Verify UUID file was not created or was removed
51 + uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
52 + _, err := os.Stat(uuidPath)
53 + assert.True(t, os.IsNotExist(err), "UUID file should not exist when opted out")
54 + })
55 +
56 + t.Run("opt-out via config", func(t *testing.T) {
57 + t.Parallel()
58 +
59 + // Create a new node
60 + node := harness.NewT(t).NewNode().Init()
61 +
62 + // Set opt-out via config
63 + node.IPFS("config", "Plugins.Plugins.telemetry.Config.Mode", "off")
64 +
65 + // Enable debug logging
66 + node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
67 +
68 + // Capture daemon output
69 + stdout := &harness.Buffer{}
70 + stderr := &harness.Buffer{}
71 +
72 + // Start daemon with output capture
73 + node.StartDaemonWithReq(harness.RunRequest{
74 + CmdOpts: []harness.CmdOpt{
75 + harness.RunWithStdout(stdout),
76 + harness.RunWithStderr(stderr),
77 + },
78 + }, "")
79 +
80 + time.Sleep(500 * time.Millisecond)
81 +
82 + // Get daemon output
83 + output := stdout.String() + stderr.String()
84 +
85 + // Check that telemetry is disabled
86 + assert.Contains(t, output, "telemetry disabled via opt-out", "Expected telemetry disabled message")
87 + assert.Contains(t, output, "telemetry collection skipped: opted out", "Expected telemetry skipped message")
88 +
89 + // Stop daemon
90 + node.StopDaemon()
91 +
92 + // Verify UUID file was not created or was removed
93 + uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
94 + _, err := os.Stat(uuidPath)
95 + assert.True(t, os.IsNotExist(err), "UUID file should not exist when opted out")
96 + })
97 +
98 + t.Run("opt-out removes existing UUID file", func(t *testing.T) {
99 + t.Parallel()
100 +
101 + // Create a new node
102 + node := harness.NewT(t).NewNode().Init()
103 +
104 + // Create a UUID file manually to simulate previous telemetry run
105 + uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
106 + testUUID := "test-uuid-12345"
107 + err := os.WriteFile(uuidPath, []byte(testUUID), 0600)
108 + require.NoError(t, err, "Failed to create test UUID file")
109 +
110 + // Verify file exists
111 + _, err = os.Stat(uuidPath)
112 + require.NoError(t, err, "UUID file should exist before opt-out")
113 +
114 + // Set the opt-out environment variable
115 + node.Runner.Env["IPFS_TELEMETRY"] = "off"
116 + node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
117 +
118 + // Capture daemon output
119 + stdout := &harness.Buffer{}
120 + stderr := &harness.Buffer{}
121 +
122 + // Start daemon with output capture
123 + node.StartDaemonWithReq(harness.RunRequest{
124 + CmdOpts: []harness.CmdOpt{
125 + harness.RunWithStdout(stdout),
126 + harness.RunWithStderr(stderr),
127 + },
128 + }, "")
129 +
130 + time.Sleep(500 * time.Millisecond)
131 +
132 + // Get daemon output
133 + output := stdout.String() + stderr.String()
134 +
135 + // Check that UUID file was removed
136 + assert.Contains(t, output, "removed existing telemetry UUID file due to opt-out", "Expected UUID removal message")
137 +
138 + // Stop daemon
139 + node.StopDaemon()
140 +
141 + // Verify UUID file was removed
142 + _, err = os.Stat(uuidPath)
143 + assert.True(t, os.IsNotExist(err), "UUID file should be removed after opt-out")
144 + })
145 +
146 + t.Run("telemetry enabled shows info message", func(t *testing.T) {
147 + t.Parallel()
148 +
149 + // Create a new node
150 + node := harness.NewT(t).NewNode().Init()
151 +
152 + // Capture daemon output
153 + stdout := &harness.Buffer{}
154 + stderr := &harness.Buffer{}
155 +
156 + // Don't set opt-out, so telemetry will be enabled
157 + // This should trigger the info message on first run
158 + node.StartDaemonWithReq(harness.RunRequest{
159 + CmdOpts: []harness.CmdOpt{
160 + harness.RunWithStdout(stdout),
161 + harness.RunWithStderr(stderr),
162 + },
163 + }, "")
164 +
165 + time.Sleep(500 * time.Millisecond)
166 +
167 + // Get daemon output
168 + output := stdout.String() + stderr.String()
169 +
170 + // First run - should show info message
171 + assert.Contains(t, output, "Anonymous telemetry")
172 + assert.Contains(t, output, "No data sent yet", "Expected no data sent message")
173 + assert.Contains(t, output, "To opt-out before collection starts", "Expected opt-out instructions")
174 + assert.Contains(t, output, "Learn more:", "Expected learn more link")
175 +
176 + // Stop daemon
177 + node.StopDaemon()
178 +
179 + // Verify UUID file was created
180 + uuidPath := filepath.Join(node.Dir, "telemetry_uuid")
181 + _, err := os.Stat(uuidPath)
182 + assert.NoError(t, err, "UUID file should exist when daemon started without telemetry opt-out")
183 + })
184 +}