diff --git a/packages/node_modules/@node-red/runtime/lib/api/settings.js b/packages/node_modules/@node-red/runtime/lib/api/settings.js index 1aa335f1a..634f5dbf3 100644 --- a/packages/node_modules/@node-red/runtime/lib/api/settings.js +++ b/packages/node_modules/@node-red/runtime/lib/api/settings.js @@ -161,6 +161,8 @@ var api = module.exports = { safeSettings.diagnostics.ui = false; // cannot have UI without endpoint } + safeSettings.telemetryEnabled = runtime.telemetry.isEnabled() + safeSettings.runtimeState = { //unless runtimeState.ui and runtimeState.enabled are explicitly true, they will default to false. enabled: !!runtime.settings.runtimeState && runtime.settings.runtimeState.enabled === true, @@ -213,7 +215,19 @@ var api = module.exports = { } var currentSettings = runtime.settings.getUserSettings(username)||{}; currentSettings = extend(currentSettings, opts.settings); + try { + if (currentSettings.hasOwnProperty("telemetryEnabled")) { + // This is a global setting that is being set by the user. It should + // not be stored per-user as it applies to the whole runtime. + const telemetryEnabled = currentSettings.telemetryEnabled; + delete currentSettings.telemetryEnabled; + if (telemetryEnabled) { + runtime.telemetry.enable() + } else { + runtime.telemetry.disable() + } + } return runtime.settings.setUserSettings(username, currentSettings).then(function() { runtime.log.audit({event: "settings.update",username:username}, opts.req); return; diff --git a/packages/node_modules/@node-red/runtime/lib/index.js b/packages/node_modules/@node-red/runtime/lib/index.js index 4ac7cfb5b..3251ff2fa 100644 --- a/packages/node_modules/@node-red/runtime/lib/index.js +++ b/packages/node_modules/@node-red/runtime/lib/index.js @@ -23,6 +23,7 @@ var library = require("./library"); var plugins = require("./plugins"); var settings = require("./settings"); const multiplayer = require("./multiplayer"); +const telemetry = require("./telemetry"); var express = require("express"); var path = require('path'); @@ -135,6 +136,7 @@ function start() { return i18n.registerMessageCatalog("runtime",path.resolve(path.join(__dirname,"..","locales")),"runtime.json") .then(function() { return storage.init(runtime)}) .then(function() { return settings.load(storage)}) + .then(function() { return telemetry.init(runtime)}) .then(function() { return library.init(runtime)}) .then(function() { return multiplayer.init(runtime)}) .then(function() { @@ -337,6 +339,7 @@ var runtime = { library: library, exec: exec, util: util, + telemetry: telemetry, get adminApi() { return adminApi }, get adminApp() { return adminApp }, get nodeApp() { return nodeApp }, diff --git a/packages/node_modules/@node-red/runtime/lib/telemetry/index.js b/packages/node_modules/@node-red/runtime/lib/telemetry/index.js new file mode 100644 index 000000000..fef1cc1db --- /dev/null +++ b/packages/node_modules/@node-red/runtime/lib/telemetry/index.js @@ -0,0 +1,189 @@ +const path = require('path') +const fs = require('fs/promises') +const semver = require('semver') +const cronosjs = require('cronosjs') + +const METRICS_DIR = path.join(__dirname, 'metrics') +const INITIAL_PING_DELAY = 1000 * 60 * 30 // 30 minutes from startup + +let runtime + +let scheduleTask + +async function gather () { + let metricFiles = await fs.readdir(METRICS_DIR) + metricFiles = metricFiles.filter(name => /^\d+-.*\.js$/.test(name)) + metricFiles.sort() + + const metrics = {} + + for (let i = 0, l = metricFiles.length; i < l; i++) { + const metricModule = require(path.join(METRICS_DIR, metricFiles[i])) + let result = metricModule(runtime) + if (!!result && (typeof result === 'object' || typeof result === 'function') && typeof result.then === 'function') { + result = await result + } + const keys = Object.keys(result) + keys.forEach(key => { + const keyParts = key.split('.') + let p = metrics + keyParts.forEach((part, index) => { + if (index < keyParts.length - 1) { + if (!p[part]) { + p[part] = {} + } + p = p[part] + } else { + p[part] = result[key] + } + }) + }) + } + return metrics +} + +async function report () { + if (!isTelemetryEnabled()) { + return + } + // If enabled, gather metrics + const metrics = await gather() + console.log(JSON.stringify(metrics, null, 2)) + + // Post metrics to endpoint - handle any error silently + + const { got } = await import('got') + runtime.log.debug('Sending telemetry') + const response = await got.post('https://telemetry.nodered.org/ping', { + json: metrics, + responseType: 'json', + headers: { + 'User-Agent': `Node-RED/${runtime.settings.version}` + } + }).json().catch(err => { + // swallow errors + runtime.log.debug('Failed to send telemetry: ' + err.toString()) + }) + // Example response: + // { 'node-red': { latest: '4.0.9', next: '4.1.0-beta.1.9' } } + runtime.log.debug(`Telemetry response: ${JSON.stringify(response)}`) + // Get response from endpoint + if (response?.['node-red']) { + const currentVersion = metrics.env['node-red'] + if (semver.valid(currentVersion)) { + const latest = response['node-red'].latest + const next = response['node-red'].next + let updatePayload + if (semver.lt(currentVersion, latest)) { + // Case one: current < latest + runtime.log.info(`A new version of Node-RED is available: ${latest}`) + updatePayload = { version: latest } + } else if (semver.gt(currentVersion, latest) && semver.lt(currentVersion, next)) { + // Case two: current > latest && current < next + runtime.log.info(`A new beta version of Node-RED is available: ${next}`) + updatePayload = { version: next } + } + + if (updatePayload && isUpdateNotificationEnabled()) { + runtime.events.emit("runtime-event",{id:"update-available", payload: updatePayload, retain: true}); + } + } + } +} + +function isTelemetryEnabled () { + // If NODE_RED_DISABLE_TELEMETRY was set, or --no-telemetry was specified, + // the settings object will have been updated to disable telemetry explicitly + + // If there are no telemetry settings then the user has not had a chance + // to opt out yet - so keep it disabled until they do + + const telemetrySettings = runtime.settings.get('telemetry') + const runtimeTelemetryEnabled = runtime.settings.get('telemetryEnabled') + + if (telemetrySettings === undefined && runtimeTelemetryEnabled === undefined) { + // No telemetry settings - so keep it disabled + return undefined + } + + // If there are telemetry settings, use what it says + if (telemetrySettings && telemetrySettings.enabled !== undefined) { + return telemetrySettings.enabled + } + + // User has made a choice; defer to that + if (runtimeTelemetryEnabled !== undefined) { + return runtimeTelemetryEnabled + } + + // At this point, we have no sign the user has consented to telemetry, so + // keep disabled - but return undefined as a false-like value to distinguish + // it from the explicit disable above + return undefined +} + +function isUpdateNotificationEnabled () { + const telemetrySettings = runtime.settings.get('telemetry') || {} + return telemetrySettings.updateNotification !== false +} +/** + * Start the telemetry schedule + */ +function startTelemetry () { + if (scheduleTask) { + // Already scheduled - nothing left to do + return + } + + const pingTime = new Date(Date.now() + INITIAL_PING_DELAY) + const pingMinutes = '*'//pingTime.getMinutes() + const pingHours = '*'//pingTime.getHours() + const pingSchedule = `${pingMinutes} ${pingHours} * * *` + runtime.log.debug(`Telemetry enabled. Schedule: ${pingSchedule}`) + + scheduleTask = cronosjs.scheduleTask(pingSchedule, () => { + report() + }) +} + +function stopTelemetry () { + if (scheduleTask) { + runtime.log.debug(`Telemetry disabled`) + scheduleTask.stop() + scheduleTask = null + } +} + +module.exports = { + init: (_runtime) => { + runtime = _runtime + if (isTelemetryEnabled()) { + startTelemetry() + } + }, + /** + * Enable telemetry via user opt-in in the editor + */ + enable: () => { + if (runtime.settings.available()) { + runtime.settings.set('telemetryEnabled', true) + } + startTelemetry() + }, + + /** + * Disable telemetry via user opt-in in the editor + */ + disable: () => { + if (runtime.settings.available()) { + runtime.settings.set('telemetryEnabled', false) + } + stopTelemetry() + }, + + /** + * Get telemetry enabled status + * @returns {boolean} true if telemetry is enabled, false if disabled, undefined if not set + */ + isEnabled: isTelemetryEnabled +} \ No newline at end of file diff --git a/packages/node_modules/@node-red/runtime/lib/telemetry/metrics/01-core.js b/packages/node_modules/@node-red/runtime/lib/telemetry/metrics/01-core.js new file mode 100644 index 000000000..acac829fb --- /dev/null +++ b/packages/node_modules/@node-red/runtime/lib/telemetry/metrics/01-core.js @@ -0,0 +1,5 @@ +module.exports = (runtime) => { + return { + instanceId: runtime.settings.get('instanceId') + } +} diff --git a/packages/node_modules/@node-red/runtime/lib/telemetry/metrics/02-os.js b/packages/node_modules/@node-red/runtime/lib/telemetry/metrics/02-os.js new file mode 100644 index 000000000..ae2a31859 --- /dev/null +++ b/packages/node_modules/@node-red/runtime/lib/telemetry/metrics/02-os.js @@ -0,0 +1,9 @@ +const os = require('os') + +module.exports = (_) => { + return { + 'os.type': os.type(), + 'os.release': os.release(), + 'os.arch': os.arch() + } +} diff --git a/packages/node_modules/@node-red/runtime/lib/telemetry/metrics/03-env.js b/packages/node_modules/@node-red/runtime/lib/telemetry/metrics/03-env.js new file mode 100644 index 000000000..812c3ee4e --- /dev/null +++ b/packages/node_modules/@node-red/runtime/lib/telemetry/metrics/03-env.js @@ -0,0 +1,8 @@ +const process = require('process') + +module.exports = async (runtime) => { + return { + 'env.nodejs': process.version.replace(/^v/, ''), + 'env.node-red': runtime.settings.version + } +} diff --git a/packages/node_modules/@node-red/runtime/package.json b/packages/node_modules/@node-red/runtime/package.json index e6f1ca0d0..03ef360d2 100644 --- a/packages/node_modules/@node-red/runtime/package.json +++ b/packages/node_modules/@node-red/runtime/package.json @@ -20,9 +20,11 @@ "@node-red/util": "4.1.0-beta.0", "async-mutex": "0.5.0", "clone": "2.1.2", + "cronosjs": "1.7.1", "express": "4.21.2", "fs-extra": "11.3.0", "json-stringify-safe": "5.0.1", - "rfdc": "^1.3.1" + "rfdc": "^1.3.1", + "semver": "7.7.1" } } diff --git a/packages/node_modules/node-red/red.js b/packages/node_modules/node-red/red.js index 5f3c9da25..d98b69f8f 100755 --- a/packages/node_modules/node-red/red.js +++ b/packages/node_modules/node-red/red.js @@ -63,7 +63,8 @@ var knownOpts = { "verbose": Boolean, "safe": Boolean, "version": Boolean, - "define": [String, Array] + "define": [String, Array], + "no-telemetry": Boolean }; var shortHands = { "?":["--help"], @@ -97,6 +98,7 @@ if (parsedArgs.help) { console.log(" --safe enable safe mode"); console.log(" -D, --define X=Y overwrite value in settings file"); console.log(" --version show version information"); + console.log(" --no-telemetry do not share usage data with the Node-RED project"); console.log(" -?, --help show this help"); console.log(" admin run an admin command"); console.log(""); @@ -222,6 +224,10 @@ if (process.env.NODE_RED_ENABLE_TOURS) { settings.editorTheme.tours = !/^false$/i.test(process.env.NODE_RED_ENABLE_TOURS); } +if (parsedArgs.telemetry === false || process.env.NODE_RED_DISABLE_TELEMETRY) { + settings.telemetry = settings.telemetry || {}; + settings.telemetry.enabled = false; +} var defaultServerSettings = { "x-powered-by": false