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
+}