Browse Source

Anonymous Usage Reporting

Jakob Borg 11 years ago
parent
commit
f40f3b3b7b
7 changed files with 259 additions and 12 deletions
  1. 0 0
      auto/gui.files.go
  2. 58 1
      cmd/syncthing/gui.go
  3. 12 0
      cmd/syncthing/main.go
  4. 109 0
      cmd/syncthing/usage_report.go
  5. 4 0
      config/config.go
  6. 49 10
      gui/app.js
  7. 27 1
      gui/index.html

File diff suppressed because it is too large
+ 0 - 0
auto/gui.files.go


+ 58 - 1
cmd/syncthing/gui.go

@@ -98,6 +98,7 @@ func startGUI(cfg config.GUIConfiguration, assetDir string, m *model.Model) erro
 	router.Get("/rest/system", restGetSystem)
 	router.Get("/rest/errors", restGetErrors)
 	router.Get("/rest/discovery", restGetDiscovery)
+	router.Get("/rest/report", restGetReport)
 	router.Get("/qr/:text", getQR)
 
 	router.Post("/rest/config", restPostConfig)
@@ -107,6 +108,8 @@ func startGUI(cfg config.GUIConfiguration, assetDir string, m *model.Model) erro
 	router.Post("/rest/error", restPostError)
 	router.Post("/rest/error/clear", restClearErrors)
 	router.Post("/rest/discovery/hint", restPostDiscoveryHint)
+	router.Post("/rest/report/enable", restPostReportEnable)
+	router.Post("/rest/report/disable", restPostReportDisable)
 
 	mr := martini.New()
 	mr.Use(csrfMiddleware)
@@ -195,7 +198,7 @@ func restGetConfig(w http.ResponseWriter) {
 	json.NewEncoder(w).Encode(encCfg)
 }
 
-func restPostConfig(req *http.Request) {
+func restPostConfig(req *http.Request, m *model.Model) {
 	var newCfg config.Configuration
 	err := json.NewDecoder(req.Body).Decode(&newCfg)
 	if err != nil {
@@ -242,6 +245,29 @@ func restPostConfig(req *http.Request) {
 			}
 		}
 
+		if newCfg.Options.UREnabled && !cfg.Options.UREnabled {
+			// UR was enabled
+			cfg.Options.UREnabled = true
+			cfg.Options.URDeclined = false
+			cfg.Options.URAccepted = usageReportVersion
+			// Set the corresponding options in newCfg so we don't trigger the restart check if this was the only option change
+			newCfg.Options.URDeclined = false
+			newCfg.Options.URAccepted = usageReportVersion
+			sendUsageRport(m)
+			go usageReportingLoop(m)
+		} else if !newCfg.Options.UREnabled && cfg.Options.UREnabled {
+			// UR was disabled
+			cfg.Options.UREnabled = false
+			cfg.Options.URDeclined = true
+			cfg.Options.URAccepted = 0
+			// Set the corresponding options in newCfg so we don't trigger the restart check if this was the only option change
+			newCfg.Options.URDeclined = true
+			newCfg.Options.URAccepted = 0
+			stopUsageReporting()
+		} else {
+			cfg.Options.URDeclined = newCfg.Options.URDeclined
+		}
+
 		if !reflect.DeepEqual(cfg.Options, newCfg.Options) {
 			configInSync = false
 		}
@@ -347,6 +373,10 @@ func restGetDiscovery(w http.ResponseWriter) {
 	json.NewEncoder(w).Encode(discoverer.All())
 }
 
+func restGetReport(w http.ResponseWriter, m *model.Model) {
+	json.NewEncoder(w).Encode(reportData(m))
+}
+
 func getQR(w http.ResponseWriter, params martini.Params) {
 	code, err := qr.Encode(params["text"], qr.M)
 	if err != nil {
@@ -358,6 +388,33 @@ func getQR(w http.ResponseWriter, params martini.Params) {
 	w.Write(code.PNG())
 }
 
+func restPostReportEnable(m *model.Model) {
+	if cfg.Options.UREnabled {
+		return
+	}
+
+	cfg.Options.UREnabled = true
+	cfg.Options.URDeclined = false
+	cfg.Options.URAccepted = usageReportVersion
+
+	go usageReportingLoop(m)
+	sendUsageRport(m)
+	saveConfig()
+}
+
+func restPostReportDisable(m *model.Model) {
+	if !cfg.Options.UREnabled {
+		return
+	}
+
+	cfg.Options.UREnabled = false
+	cfg.Options.URDeclined = true
+	cfg.Options.URAccepted = 0
+
+	stopUsageReporting()
+	saveConfig()
+}
+
 func basic(username string, passhash string) http.HandlerFunc {
 	return func(res http.ResponseWriter, req *http.Request) {
 		if validAPIKey(req.Header.Get("X-API-Key")) {

+ 12 - 0
cmd/syncthing/main.go

@@ -393,6 +393,18 @@ func main() {
 		}
 	}
 
+	if cfg.Options.UREnabled && cfg.Options.URAccepted < usageReportVersion {
+		l.Infoln("Anonymous usage report has changed; revoking acceptance")
+		cfg.Options.UREnabled = false
+	}
+	if cfg.Options.UREnabled {
+		go usageReportingLoop(m)
+		go func() {
+			time.Sleep(10 * time.Minute)
+			sendUsageRport(m)
+		}()
+	}
+
 	<-stop
 	l.Okln("Exiting")
 }

+ 109 - 0
cmd/syncthing/usage_report.go

@@ -0,0 +1,109 @@
+package main
+
+import (
+	"bytes"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/json"
+	"net/http"
+	"runtime"
+	"strings"
+	"time"
+
+	"github.com/calmh/syncthing/model"
+)
+
+// Current version number of the usage report, for acceptance purposes. If
+// fields are added or changed this integer must be incremented so that users
+// are prompted for acceptance of the new report.
+const usageReportVersion = 1
+
+var stopUsageReportingCh = make(chan struct{})
+
+func reportData(m *model.Model) map[string]interface{} {
+	res := make(map[string]interface{})
+	res["uniqueID"] = strings.ToLower(certID([]byte(myID)))[:6]
+	res["version"] = Version
+	res["platform"] = runtime.GOOS + "-" + runtime.GOARCH
+	res["numRepos"] = len(cfg.Repositories)
+	res["numNodes"] = len(cfg.Nodes)
+
+	var totFiles, maxFiles int
+	var totBytes, maxBytes int64
+	for _, repo := range cfg.Repositories {
+		files, _, bytes := m.GlobalSize(repo.ID)
+		totFiles += files
+		totBytes += bytes
+		if files > maxFiles {
+			maxFiles = files
+		}
+		if bytes > maxBytes {
+			maxBytes = bytes
+		}
+	}
+
+	res["totFiles"] = totFiles
+	res["repoMaxFiles"] = maxFiles
+	res["totMiB"] = totBytes / 1024 / 1024
+	res["repoMaxMiB"] = maxBytes / 1024 / 1024
+
+	var mem runtime.MemStats
+	runtime.ReadMemStats(&mem)
+	res["memoryUsageMiB"] = mem.Sys / 1024 / 1024
+
+	var perf float64
+	for i := 0; i < 5; i++ {
+		p := cpuBench()
+		if p > perf {
+			perf = p
+		}
+	}
+	res["sha256Perf"] = perf
+
+	return res
+}
+
+func sendUsageRport(m *model.Model) error {
+	d := reportData(m)
+	var b bytes.Buffer
+	json.NewEncoder(&b).Encode(d)
+	_, err := http.Post("https://data.syncthing.net/newdata", "application/json", &b)
+	return err
+}
+
+func usageReportingLoop(m *model.Model) {
+	l.Infoln("Starting usage reporting")
+	t := time.NewTicker(86400 * time.Second)
+loop:
+	for {
+		select {
+		case <-stopUsageReportingCh:
+			break loop
+		case <-t.C:
+			sendUsageRport(m)
+		}
+	}
+	l.Infoln("Stopping usage reporting")
+}
+
+func stopUsageReporting() {
+	stopUsageReportingCh <- struct{}{}
+}
+
+// Returns CPU performance as a measure of single threaded SHA-256 MiB/s
+func cpuBench() float64 {
+	chunkSize := 100 * 1 << 10
+	h := sha256.New()
+	bs := make([]byte, chunkSize)
+	rand.Reader.Read(bs)
+
+	t0 := time.Now()
+	b := 0
+	for time.Since(t0) < 125*time.Millisecond {
+		h.Write(bs)
+		b += chunkSize
+	}
+	h.Sum(nil)
+	d := time.Since(t0)
+	return float64(int(float64(b)/d.Seconds()/(1<<20)*100)) / 100
+}

+ 4 - 0
config/config.go

@@ -157,6 +157,10 @@ type OptionsConfiguration struct {
 	StartBrowser       bool     `xml:"startBrowser" default:"true"`
 	UPnPEnabled        bool     `xml:"upnpEnabled" default:"true"`
 
+	UREnabled  bool `xml:"urEnabled"`  // If true, send usage reporting data
+	URDeclined bool `xml:"urDeclined"` // If true, don't ask again
+	URAccepted int  `xml:"urAccepted"` // Accepted usage reporting version
+
 	Deprecated_ReadOnly   bool   `xml:"readOnly,omitempty" json:"-"`
 	Deprecated_GUIEnabled bool   `xml:"guiEnabled,omitempty" json:"-"`
 	Deprecated_GUIAddress string `xml:"guiAddress,omitempty" json:"-"`

+ 49 - 10
gui/app.js

@@ -30,21 +30,24 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
     $scope.seenError = '';
     $scope.model = {};
     $scope.repos = {};
+    $scope.reportData = {};
+    $scope.reportPreview = false;
 
     // Strings before bools look better
     $scope.settings = [
-    {id: 'ListenStr', descr: 'Sync Protocol Listen Addresses', type: 'text', restart: true},
-    {id: 'MaxSendKbps', descr: 'Outgoing Rate Limit (KiB/s)', type: 'number', restart: true},
-    {id: 'RescanIntervalS', descr: 'Rescan Interval (s)', type: 'number', restart: true},
-    {id: 'ReconnectIntervalS', descr: 'Reconnect Interval (s)', type: 'number', restart: true},
-    {id: 'ParallelRequests', descr: 'Max Outstanding Requests', type: 'number', restart: true},
-    {id: 'MaxChangeKbps', descr: 'Max File Change Rate (KiB/s)', type: 'number', restart: true},
-
-    {id: 'GlobalAnnEnabled', descr: 'Global Discovery', type: 'bool', restart: true},
-    {id: 'LocalAnnEnabled', descr: 'Local Discovery', type: 'bool', restart: true},
-    {id: 'LocalAnnPort', descr: 'Local Discovery Port', type: 'number', restart: true},
+    {id: 'ListenStr', descr: 'Sync Protocol Listen Addresses', type: 'text'},
+    {id: 'MaxSendKbps', descr: 'Outgoing Rate Limit (KiB/s)', type: 'number'},
+    {id: 'RescanIntervalS', descr: 'Rescan Interval (s)', type: 'number'},
+    {id: 'ReconnectIntervalS', descr: 'Reconnect Interval (s)', type: 'number'},
+    {id: 'ParallelRequests', descr: 'Max Outstanding Requests', type: 'number'},
+    {id: 'MaxChangeKbps', descr: 'Max File Change Rate (KiB/s)', type: 'number'},
+
+    {id: 'LocalAnnPort', descr: 'Local Discovery Port', type: 'number'},
+    {id: 'LocalAnnEnabled', descr: 'Local Discovery', type: 'bool'},
+    {id: 'GlobalAnnEnabled', descr: 'Global Discovery', type: 'bool'},
     {id: 'StartBrowser', descr: 'Start Browser', type: 'bool'},
     {id: 'UPnPEnabled', descr: 'Enable UPnP', type: 'bool'},
+    {id: 'UREnabled', descr: 'Anonymous Usage Reporting', type: 'bool'},
     ];
 
     $scope.guiSettings = [
@@ -544,11 +547,47 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
             $scope.repos = repoMap($scope.config.Repositories);
 
             $scope.refresh();
+
+            if (!$scope.config.Options.UREnabled && !$scope.config.Options.URDeclined) {
+                // If usage reporting has been neither accepted nor declined,
+                // we want to ask the user to make a choice. But we don't want
+                // to bug them during initial setup, so we set a cookie with
+                // the time of the first visit. When that cookie is present
+                // and the time is more than four hours ago, we ask the
+                // question.
+
+                var firstVisit = document.cookie.replace(/(?:(?:^|.*;\s*)firstVisit\s*\=\s*([^;]*).*$)|^.*$/, "$1");
+                if (!firstVisit) {
+                    document.cookie = "firstVisit=" + Date.now() + ";max-age=" + 30*24*3600;
+                } else {
+                    if (+firstVisit < Date.now() - 4*3600*1000){
+                        $('#ur').modal({backdrop: 'static', keyboard: false});
+                    }
+                }
+            }
         });
 
         $http.get(urlbase + '/config/sync').success(function (data) {
             $scope.configInSync = data.configInSync;
         });
+
+        $http.get(urlbase + '/report').success(function (data) {
+            $scope.reportData = data;
+        });
+    };
+
+    $scope.acceptUR = function () {
+        $scope.config.Options.UREnabled = true;
+        $scope.config.Options.URDeclined = false;
+        $scope.saveConfig();
+        $('#ur').modal('hide');
+    };
+
+    $scope.declineUR = function () {
+        $scope.config.Options.UREnabled = false;
+        $scope.config.Options.URDeclined = true;
+        $scope.saveConfig();
+        $('#ur').modal('hide');
     };
 
     $scope.init();

+ 27 - 1
gui/index.html

@@ -564,7 +564,7 @@ found in the LICENSE file.
     <div class="modal-dialog modal-lg">
       <div class="modal-content">
         <div class="modal-header">
-          <h4 class="modal-title"> Settings</h4>
+          <h4 class="modal-title">Settings</h4>
         </div>
         <div class="modal-body">
           <form role="form">
@@ -611,6 +611,32 @@ found in the LICENSE file.
     </div>
   </div>
 
+  <!-- Usage report modal -->
+
+  <div id="ur" class="modal fade">
+    <div class="modal-dialog modal-lg">
+      <div class="modal-content">
+        <div class="modal-header alert alert-success">
+          <h4 class="modal-title">Allow Anonymous Usage Reporting?</h4>
+        </div>
+        <div class="modal-body">
+        <p>
+        The encrypted usage report is sent daily. It is used to track common platforms, repo sizes and app versions. If the reported data set is changed you will be prompted with this dialog again.
+        </p>
+        <p>
+        The aggregated statistics are publicly available at <a href="https://data.syncthing.net/">https://data.syncthing.net/</a>.
+        </p>
+        <button type="button" class="btn btn-default" ng-show="!reportPreview" ng-click="reportPreview = true">Preview Usage Report</button>
+        <pre ng-if="reportPreview"><small>{{reportData | json}}</small></pre>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-success" ng-click="acceptUR()"><span class="glyphicon glyphicon-ok"></span>&emsp;Yes</button>
+          <button type="button" class="btn btn-danger" ng-click="declineUR()"><span class="glyphicon glyphicon-remove"></span>&emsp;No</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
 
   <script src="angular.min.js"></script>
   <script src="jquery-2.0.3.min.js"></script>

Some files were not shown because too many files changed in this diff