Anonymous Usage Reporting

This commit is contained in:
Jakob Borg 2014-06-11 20:04:23 +02:00
parent 7454670b0a
commit f40f3b3b7b
7 changed files with 260 additions and 13 deletions

File diff suppressed because one or more lines are too long

View File

@ -98,6 +98,7 @@ func startGUI(cfg config.GUIConfiguration, assetDir string, m *model.Model) erro
router.Get("/rest/system", restGetSystem) router.Get("/rest/system", restGetSystem)
router.Get("/rest/errors", restGetErrors) router.Get("/rest/errors", restGetErrors)
router.Get("/rest/discovery", restGetDiscovery) router.Get("/rest/discovery", restGetDiscovery)
router.Get("/rest/report", restGetReport)
router.Get("/qr/:text", getQR) router.Get("/qr/:text", getQR)
router.Post("/rest/config", restPostConfig) 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", restPostError)
router.Post("/rest/error/clear", restClearErrors) router.Post("/rest/error/clear", restClearErrors)
router.Post("/rest/discovery/hint", restPostDiscoveryHint) router.Post("/rest/discovery/hint", restPostDiscoveryHint)
router.Post("/rest/report/enable", restPostReportEnable)
router.Post("/rest/report/disable", restPostReportDisable)
mr := martini.New() mr := martini.New()
mr.Use(csrfMiddleware) mr.Use(csrfMiddleware)
@ -195,7 +198,7 @@ func restGetConfig(w http.ResponseWriter) {
json.NewEncoder(w).Encode(encCfg) json.NewEncoder(w).Encode(encCfg)
} }
func restPostConfig(req *http.Request) { func restPostConfig(req *http.Request, m *model.Model) {
var newCfg config.Configuration var newCfg config.Configuration
err := json.NewDecoder(req.Body).Decode(&newCfg) err := json.NewDecoder(req.Body).Decode(&newCfg)
if err != nil { 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) { if !reflect.DeepEqual(cfg.Options, newCfg.Options) {
configInSync = false configInSync = false
} }
@ -347,6 +373,10 @@ func restGetDiscovery(w http.ResponseWriter) {
json.NewEncoder(w).Encode(discoverer.All()) 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) { func getQR(w http.ResponseWriter, params martini.Params) {
code, err := qr.Encode(params["text"], qr.M) code, err := qr.Encode(params["text"], qr.M)
if err != nil { if err != nil {
@ -358,6 +388,33 @@ func getQR(w http.ResponseWriter, params martini.Params) {
w.Write(code.PNG()) 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 { func basic(username string, passhash string) http.HandlerFunc {
return func(res http.ResponseWriter, req *http.Request) { return func(res http.ResponseWriter, req *http.Request) {
if validAPIKey(req.Header.Get("X-API-Key")) { if validAPIKey(req.Header.Get("X-API-Key")) {

View File

@ -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 <-stop
l.Okln("Exiting") l.Okln("Exiting")
} }

View File

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

View File

@ -157,6 +157,10 @@ type OptionsConfiguration struct {
StartBrowser bool `xml:"startBrowser" default:"true"` StartBrowser bool `xml:"startBrowser" default:"true"`
UPnPEnabled bool `xml:"upnpEnabled" 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_ReadOnly bool `xml:"readOnly,omitempty" json:"-"`
Deprecated_GUIEnabled bool `xml:"guiEnabled,omitempty" json:"-"` Deprecated_GUIEnabled bool `xml:"guiEnabled,omitempty" json:"-"`
Deprecated_GUIAddress string `xml:"guiAddress,omitempty" json:"-"` Deprecated_GUIAddress string `xml:"guiAddress,omitempty" json:"-"`

View File

@ -30,21 +30,24 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
$scope.seenError = ''; $scope.seenError = '';
$scope.model = {}; $scope.model = {};
$scope.repos = {}; $scope.repos = {};
$scope.reportData = {};
$scope.reportPreview = false;
// Strings before bools look better // Strings before bools look better
$scope.settings = [ $scope.settings = [
{id: 'ListenStr', descr: 'Sync Protocol Listen Addresses', type: 'text', restart: true}, {id: 'ListenStr', descr: 'Sync Protocol Listen Addresses', type: 'text'},
{id: 'MaxSendKbps', descr: 'Outgoing Rate Limit (KiB/s)', type: 'number', restart: true}, {id: 'MaxSendKbps', descr: 'Outgoing Rate Limit (KiB/s)', type: 'number'},
{id: 'RescanIntervalS', descr: 'Rescan Interval (s)', type: 'number', restart: true}, {id: 'RescanIntervalS', descr: 'Rescan Interval (s)', type: 'number'},
{id: 'ReconnectIntervalS', descr: 'Reconnect Interval (s)', type: 'number', restart: true}, {id: 'ReconnectIntervalS', descr: 'Reconnect Interval (s)', type: 'number'},
{id: 'ParallelRequests', descr: 'Max Outstanding Requests', type: 'number', restart: true}, {id: 'ParallelRequests', descr: 'Max Outstanding Requests', type: 'number'},
{id: 'MaxChangeKbps', descr: 'Max File Change Rate (KiB/s)', type: 'number', restart: true}, {id: 'MaxChangeKbps', descr: 'Max File Change Rate (KiB/s)', type: 'number'},
{id: 'GlobalAnnEnabled', descr: 'Global Discovery', type: 'bool', restart: true}, {id: 'LocalAnnPort', descr: 'Local Discovery Port', type: 'number'},
{id: 'LocalAnnEnabled', descr: 'Local Discovery', type: 'bool', restart: true}, {id: 'LocalAnnEnabled', descr: 'Local Discovery', type: 'bool'},
{id: 'LocalAnnPort', descr: 'Local Discovery Port', type: 'number', restart: true}, {id: 'GlobalAnnEnabled', descr: 'Global Discovery', type: 'bool'},
{id: 'StartBrowser', descr: 'Start Browser', type: 'bool'}, {id: 'StartBrowser', descr: 'Start Browser', type: 'bool'},
{id: 'UPnPEnabled', descr: 'Enable UPnP', type: 'bool'}, {id: 'UPnPEnabled', descr: 'Enable UPnP', type: 'bool'},
{id: 'UREnabled', descr: 'Anonymous Usage Reporting', type: 'bool'},
]; ];
$scope.guiSettings = [ $scope.guiSettings = [
@ -544,11 +547,47 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
$scope.repos = repoMap($scope.config.Repositories); $scope.repos = repoMap($scope.config.Repositories);
$scope.refresh(); $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) { $http.get(urlbase + '/config/sync').success(function (data) {
$scope.configInSync = data.configInSync; $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(); $scope.init();

View File

@ -564,7 +564,7 @@ found in the LICENSE file.
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title"> Settings</h4> <h4 class="modal-title">Settings</h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form role="form"> <form role="form">
@ -611,6 +611,32 @@ found in the LICENSE file.
</div> </div>
</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="angular.min.js"></script>
<script src="jquery-2.0.3.min.js"></script> <script src="jquery-2.0.3.min.js"></script>