// Collects anonymous usage stats and uncaught errors // Reports back so that we can improve WebTorrent Desktop module.exports = { init, send, logUncaughtError, logPlayAttempt } const remote = require('@electron/remote') const config = require('../../config') let telemetry function init (state) { telemetry = state.saved.telemetry // First app run if (!telemetry) { const crypto = require('crypto') telemetry = state.saved.telemetry = { userID: crypto.randomBytes(32).toString('hex') // 256-bit random ID } reset() } } function send (state) { //Disable telemetry from webtorrent return const now = new Date() telemetry.version = config.APP_VERSION telemetry.timestamp = now.toISOString() telemetry.localTime = now.toTimeString() telemetry.screens = getScreenInfo() telemetry.system = getSystemInfo() telemetry.torrentStats = getTorrentStats(state) telemetry.approxNumTorrents = telemetry.torrentStats.approxCount if (!config.IS_PRODUCTION) { // Development: telemetry used only for local debugging // Empty uncaught errors, etc at the start of every run return reset() } const get = require('simple-get') const opts = { url: config.TELEMETRY_URL, body: telemetry, json: true } get.post(opts, (err, res) => { if (err) return console.error('Error sending telemetry', err) if (res.statusCode !== 200) { return console.error(`Error sending telemetry, status code: ${res.statusCode}`) } console.log('Sent telemetry') reset() }) } function reset () { telemetry.uncaughtErrors = [] telemetry.playAttempts = { minVersion: config.APP_VERSION, total: 0, success: 0, error: 0, external: 0, abandoned: 0 } } // Track screen resolution function getScreenInfo () { return remote.screen.getAllDisplays().map((screen) => ({ width: screen.size.width, height: screen.size.height, scaleFactor: screen.scaleFactor })) } // Track basic system info like OS version and amount of RAM function getSystemInfo () { const os = require('os') return { osPlatform: process.platform, osRelease: os.type() + ' ' + os.release(), architecture: os.arch(), systemArchitecture: config.OS_SYSARCH, totalMemoryMB: roundPow2(os.totalmem() / (1 << 20)), numCores: os.cpus().length } } // Get stats like the # of torrents currently active, # in list, total size function getTorrentStats (state) { const count = state.saved.torrents.length let sizeMB = 0 const byStatus = { new: { count: 0, sizeMB: 0 }, downloading: { count: 0, sizeMB: 0 }, seeding: { count: 0, sizeMB: 0 }, paused: { count: 0, sizeMB: 0 } } // First, count torrents & total file size for (const torrent of state.saved.torrents) { const stat = byStatus[torrent.status] if (!torrent || !torrent.files || !stat) continue stat.count++ for (const file of torrent.files) { if (!file || !file.length) continue const fileSizeMB = file.length / (1 << 20) sizeMB += fileSizeMB stat.sizeMB += fileSizeMB } } // Then, round all the counts and sums to the nearest power of 2 const ret = roundTorrentStats({ count, sizeMB }) ret.byStatus = { new: roundTorrentStats(byStatus.new), downloading: roundTorrentStats(byStatus.downloading), seeding: roundTorrentStats(byStatus.seeding), paused: roundTorrentStats(byStatus.paused) } return ret } function roundTorrentStats (stats) { return { approxCount: roundPow2(stats.count), approxSizeMB: roundPow2(stats.sizeMB) } } // Rounds to the nearest power of 2, for privacy and easy bucketing. // Rounds 35 to 32, 70 to 64, 5 to 4, 1 to 1, 0 to 0. // Supports nonnegative numbers only. function roundPow2 (n) { if (n <= 0) return 0 // Otherwise, return 1, 2, 4, 8, etc by rounding in log space const log2 = Math.log(n) / Math.log(2) return 2 ** Math.round(log2) } // An uncaught error happened in the main process or in one of the windows function logUncaughtError (procName, e) { // Not initialized yet? Ignore. // Hopefully uncaught errors immediately on startup are fixed in dev if (!telemetry) return let message let stack = '' if (e == null) { message = 'Unexpected undefined error' } else if (e.error) { // Uncaught Javascript errors (window.onerror), err is an ErrorEvent if (!e.error.message) { message = 'Unexpected ErrorEvent.error: ' + Object.keys(e.error).join(' ') } else { message = e.error.message stack = e.error.stack } } else if (e.message) { // err is either an Error or a plain object {message, stack} message = e.message stack = e.stack } else { // Resource errors (captured element.onerror), err is an Event if (!e.target) { message = 'Unexpected unknown error' } else if (!e.target.error) { message = 'Unexpected resource loading error: ' + getElemString(e.target) } else { message = 'Resource error ' + getElemString(e.target) + ': ' + e.target.error.code } } if (typeof stack !== 'string') stack = 'Unexpected stack: ' + stack if (typeof message !== 'string') message = 'Unexpected message: ' + message // Remove the first part of each file path in the stack trace. // - Privacy: remove personal info like C:\Users\ // - Aggregation: this lets us find which stacktraces occur often stack = stack.replace(/\(.*app.asar/g, '(...') stack = stack.replace(/at .*app.asar/g, 'at ...') // We need to POST the telemetry object, make sure it stays < 100kb if (telemetry.uncaughtErrors.length > 20) return if (message.length > 1000) message = message.substring(0, 1000) if (stack.length > 1000) stack = stack.substring(0, 1000) // Log the app version *at the time of the error* const version = config.APP_VERSION telemetry.uncaughtErrors.push({ process: procName, message, stack, version }) } // Turns a DOM element into a string, eg "DIV.my-class.visible" function getElemString (elem) { let ret = elem.tagName try { ret += '.' + Array.from(elem.classList).join('.') } catch (err) {} return ret } // The user pressed play. Did it work, display an error, // open an external player or did user abandon the attempt? function logPlayAttempt (result) { if (!['success', 'error', 'external', 'abandoned'].includes(result)) { return console.error('Unknown play attempt result', result) } const attempts = telemetry.playAttempts attempts.total = (attempts.total || 0) + 1 attempts[result] = (attempts[result] || 0) + 1 }