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>
This commit is contained in:
Hector Sanjuan 2025-08-18 20:46:05 +02:00 committed by GitHub
parent 187fce7a6d
commit 4255cc3889
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1108 additions and 2 deletions

View File

@ -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

View File

@ -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": "<unique_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)

View File

@ -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

View File

@ -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.

122
docs/telemetry.md Normal file
View File

@ -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)

View File

@ -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...)
}

View File

@ -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 *

View File

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

View File

@ -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")
}
}

View File

@ -0,0 +1 @@
289ffed8-c770-49ae-922f-b020c8f776f2

184
test/cli/telemetry_test.go Normal file
View File

@ -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")
})
}