UNPKG

@axway/amplify-sdk

Version:

Axway Amplify SDK for Node.js

536 lines (475 loc) 15 kB
import ci from 'ci-info'; import fs from 'fs-extra'; import path, { dirname } from 'path'; import os from 'os'; import snooplogg, { StripColors, createInstanceWithDefaults } from 'snooplogg'; import * as request from '@axway/amplify-request'; import * as uuid from 'uuid'; import { isDir, writeFileSync, arch, osInfo, redact } from '@axway/amplify-utils'; import { serializeError } from 'serialize-error'; import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const logger = snooplogg('amplify-sdk:telemetry'); const { log } = logger; const { highlight } = snooplogg.styles; /** * A map of known send process exit codes. * @type {Object} */ const exitCodes = { 0: 'Success', 1: 'Error', 2: 'Already running', ALREADY_RUNNING: 2, ERROR: 1 }; /** * The number of milliseconds since the last execution before starting a new session. * @type {Number} */ const sessionTimeout = 24 * 60 * 60 * 1000; // 1 day /** * The number of events to send in a batch. * @type {Number} */ const sendBatchSize = 10; /** * The default URL to send the data to. Telemetry should always send data to production. * @type {String} */ const telemetryUrl = 'https://gatekeeper.platform.axway.com/v4/event'; /** * Sends anonymous telemetry events for Axway products to help improve our software. * Spec: https://techweb.axway.com/confluence/display/analytics/Analytics+JSON+Payload+V4 */ class Telemetry { /** * Initializes a telemetry instance. * * @param {Object} opts - Various options. * @param {String} opts.appGuid - The platform registered app guid. * @param {String} opts.appVersion - The app version. * @param {String} opts.cacheDir - The path to cache telemetry events. * @param {String} opts.environment - The environment name. * @param {Object} [opts.requestOptions] - HTTP request options with proxy settings and such to * create a `got` HTTP client. * @param {String} [opts.url] - The URL to post the telemetry events to. This option is * intended for testing purposes. * @access public */ constructor(opts) { if (!opts || typeof opts !== 'object') { throw new TypeError('Expected telemetry options to be an object'); } if (!opts.appGuid || typeof opts.appGuid !== 'string') { throw new TypeError('Expected app guid to be a non-empty string'); } if (!opts.appVersion || typeof opts.appVersion !== 'string') { throw new TypeError('Expected app version to be a non-empty string'); } if (!opts.cacheDir || typeof opts.cacheDir !== 'string') { throw new TypeError('Expected telemetry cache dir to be a non-empty string'); } if (!opts.environment || typeof opts.environment !== 'string') { throw new TypeError('Expected environment to be a non-empty string'); } if (opts.requestOptions && typeof opts.requestOptions !== 'object') { throw new TypeError('Expected telemetry request options to be an object'); } this.appDir = path.join(opts.cacheDir, opts.appGuid); this.requestOptions = opts.requestOptions; this.url = opts.url || telemetryUrl; const arch$1 = arch(); const { id, prev, ts } = this.initId('.sid'); // init static common data this.common = { app: opts.appGuid, distribution: { environment: opts.environment, version: opts.appVersion }, hardware: { arch: arch$1, id: this.initId('.hid').id }, os: { arch: arch$1, ...osInfo() }, version: '4' }; this.count = 0; if (prev) { log('Detected expired session, ending old one and starting new session'); this.sessionId = prev; this.addEvent({ event: 'session.end' }); } this.sessionId = id; if (!ts) { this.addEvent({ cpus: os.cpus().length, event: 'session.start', memory: os.totalmem(), nodePlatform: process.platform, nodeVersion: process.version }); } } /** * Prepares and adds a crash report. * * @param {Object|Error} payload - An object or `Error` with the error information. * @param {String} payload.message - The error message. * @param {String} [payload.stack] - The stack trace. * @access public */ addCrash(payload) { if (isTelemetryDisabled() || this.common.distribution.environment !== 'production') { return; } if (!payload || typeof payload !== 'object') { throw new TypeError('Expected crash payload to be an object'); } if (payload instanceof Error) { // we need to clone the error so that it can be serialized payload = serializeError(payload); } if (!payload.message || typeof payload.message !== 'string') { throw new TypeError('Expected crash payload to have a message'); } const homeDir = os.homedir(); if (typeof payload.stack === 'string') { payload.stack = [ payload.stack.split(/\r\n|\n/).map((line, i) => { if (!i) { return line; } // node internal module if (/ \(node:/.test(line)) { return line; } let m = line.match(/\(([^:)]*:)/); // istanbul ignore if if (!m) { m = line.match(/at ([^:]*:)/); if (!m) { return redact(line); } } const filename = path.basename(m[1]); let pkgDir = findDir(m[1], 'package.json'); if (!pkgDir) { // no package.json return `${m[1].startsWith(homeDir) ? '<HOME>' : ''}/<REDACTED>/${filename}`; } // we have an node package const parent = path.dirname(pkgDir); const clean = parent.replace(homeDir, '<HOME>').replace(/\/.*$/, '/<REDACTED>'); const scrubbed = pkgDir.replace(parent, clean); return line.replace(pkgDir, scrubbed); }).join('\n') ]; } else if (payload.stack && !Array.isArray(payload.stack)) { payload.stack = [ payload.stack ]; } this.writeEvent('crash.report', payload); } /** * Handles incoming add event requests and writes the event to disk. * * @param {Object} payload - An object containing the event name and any event specific data. * @param {String} payload.event - The event name. * @access public */ addEvent(payload) { if (isTelemetryDisabled()) { return; } if (!payload || typeof payload !== 'object') { throw new TypeError('Expected telemetry payload to be an object'); } const { event } = payload; delete payload.event; if (!event || typeof event !== 'string') { throw new TypeError('Expected telemetry payload to have an event name'); } // write the event to disk this.writeEvent(event, redact(payload, { props: [ 'clientSecret', 'password', 'username' ], replacements: [ [ /\/.+\//, '/<REDACTED>/' ] ] })); } /** * Initializes the hardware or session id if needed and bumps the timestamp. * * @param {String} filename - The name of the file containing the id. * @returns {Object} * @access private */ initId(filename) { const file = path.join(this.appDir, filename); let id, prev, ts; try { ({ id, ts } = JSON.parse(fs.readFileSync(file, 'utf-8'))); ts = Date.parse(ts); if (!uuid.validate(id) || isNaN(ts)) { throw new Error(); } if ((Date.now() - ts) > sessionTimeout) { prev = id; throw new Error(); } } catch (err) { id = uuid.v4(); ts = null; } log(`Initializing id ${id}: ${highlight(file)}`); writeFileSync(file, JSON.stringify({ id, ts: new Date().toISOString() }), 'utf-8'); return { id, prev, ts }; } /** * Spawns a child process to send any pending telemetry events. * * @param {Object} [opts] - Various options. * @param {Boolean} [opts.wait] - When `true`, blocks until the send process exits. * @returns {Promise} * @access public */ async send(opts) { if (isTelemetryDisabled()) { return; } const child = spawn(process.execPath, [ __filename ], { stdio: [ 'pipe', 'pipe', 'pipe', 'ipc' ], windowsHide: true }); const { pid } = child; child.send({ appDir: this.appDir, requestOptions: this.requestOptions, url: this.url, wait: opts?.wait }); if (opts?.wait) { log(`Forked send process (pid: ${pid}), waiting for exit... `); await new Promise(resolve => { child.on('close', code => { const debugLog = path.join(this.appDir, `debug-${pid}.log`); if (fs.existsSync(debugLog)) { logger(`send-${pid}`).log(fs.readFileSync(debugLog, 'utf-8').trim()); fs.renameSync(debugLog, path.join(this.appDir, 'debug.log')); } log(`Send process ${pid} exited with code ${code} (${exitCodes[code] || 'Unknown'})`); resolve(); }); }); } else { log(`Forked send process (pid: ${pid}), unreferencing child process... `); child.unref(); } } /** * Writes the event to disk. * * @param {String} event - The event name. * @param {Object} data - The event data payload. * @access private */ writeEvent(event, data) { const id = uuid.v4(); const now = new Date(); const ts = `${now.toISOString().replace(/[\WZ]*/ig, '').replace('T', '-')}-${String(++this.count).padStart(4, '0')}`; const file = path.join(this.appDir, `${ts}.json`); const evt = { ...this.common, data, event, id, session: { id: this.sessionId }, timestamp: now.getTime() }; log(`Writing event: ${highlight(file)}`); log(evt); writeFileSync(file, JSON.stringify(evt)); } } /** * When Telemetry::send() is called, it spawns this file using `fork()` which conveniently creates * an IPC tunnel. The parent then immediately sends this child process a message with the app dir * and other info so that it can send the actual messages without blocking the parent. * * This child process will not exit until the IPC tunnel has been closed via process.disconnect(). * The only way to get output from this child process is to either set SNOOPLOGG=* and call * Telemetry::send({ wait: true }) -or- look in the app's telemetry data dir for the debug.log file. */ process.on('message', async msg => { // istanbul ignore if if (!msg || !msg.appDir || !isDir(msg.appDir)) { process.disconnect(); return; } const { appDir, requestOptions, url, wait } = msg; const lockFile = path.join(appDir, '.lock'); const startTime = Date.now(); const logFile = fs.createWriteStream(path.join(appDir, wait ? `debug-${process.pid}.log` : 'debug.log')); const formatter = new StripColors(); formatter.pipe(logFile); const logger = createInstanceWithDefaults() .config({ theme: 'detailed' }) .enable('*') .pipe(formatter, { flush: true }) .snoop() .ns('amplify-sdk:telemetry:send'); const { error, log, warn } = logger; try { // if the parent isn't waiting for us, disconnect so the IPC tunnel will be closed and this // process can exit gracefully if (!wait) { process.disconnect(); } log('Telemetry Send Debug Log'); if (process.env.AXWAY_CLI) { log(`Axway CLI, version ${process.env.AXWAY_CLI}`); } log(`Amplify SDK, version ${fs.readJsonSync(path.resolve(__dirname, '..', 'package.json')).version}`); log(`PID: ${process.pid}`); log(`Batch size: ${sendBatchSize}`); // this function writes the lock file with the current pid to prevent another send process // from sending duplicate events const acquireLock = async () => { for (let i = 1; i < 4; i++) { try { log(`Attempt ${i}: Writing lock file: ${lockFile}`); writeFileSync(lockFile, String(process.pid), { flag: 'wx' }); log(`Attempt ${i}: Successfully acquired lock`); return true; } catch (e) { const contents = fs.readFileSync(lockFile, 'utf-8').trim(); const pid = parseInt(contents, 10); // istanbul ignore next if (isNaN(pid)) { log(`Attempt ${i}: Lock file exists, but has bad pid: "${contents}"`); fs.removeSync(lockFile); } else { try { // check if pid is still running process.kill(pid, 0); log(`Attempt ${i}: Another send process (pid: ${pid}) is currently running`); return false; } catch (e2) { log(`Attempt ${i}: Lock file exists, but has stale pid, continuing...`); fs.removeSync(lockFile); } } // wait before retrying await new Promise(resolve => setTimeout(resolve, 50)); } } log('Giving up after 3 attempts'); return false; }; if (!await acquireLock()) { process.exitCode = exitCodes.ALREADY_RUNNING; return; } const got = request.init(requestOptions); let last = 0; // loop until all events are sent for (let batchCounter = 1; true; batchCounter++) { const events = fs.readdirSync(appDir).filter(filename => filename.endsWith('.json')).sort(); if (!events.length || events.length === last) { if (batchCounter === 1) { log('No events to send'); } break; } last = events.length; log(`Batch ${batchCounter}: Found ${events.length} event${events.length !== 1 ? 's' : ''}`); const batch = []; for (const filename of events) { const file = path.join(appDir, filename); try { const event = fs.readJsonSync(file); if (!event.event) { throw new Error('Incomplete event data'); } log(`Batch ${batchCounter}: Adding event ${event.timestamp} "${event.event}"`); batch.push({ event, file }); if (batch.length >= sendBatchSize) { break; } } catch (err) { warn(`Batch ${batchCounter}: Bad event ${filename}, deleting`); fs.removeSync(file); } } if (!batch.length) { break; } log(`Batch ${batchCounter}: Sending batch with ${batch.length} event${batch.length !== 1 ? 's' : ''}`); await got.post({ json: batch.map(b => b.event), retry: 0, timeout: 10000, url }); log(`Batch ${batchCounter}: Successfully sent ${batch.length} event${batch.length !== 1 ? 's' : ''}`); try { for (const { file } of batch) { log(`Removing ${file}`); fs.removeSync(file); } } catch (err) { // istanbul ignore next warn(err); } } } catch (err) { error(err); process.exitCode = exitCodes.ERROR; } finally { fs.removeSync(lockFile); log(`Finished in ${((Date.now() - startTime) / 1000).toFixed(1)} seconds`); logFile.close(); if (wait) { process.disconnect(); } } }); /** * Scans up the directory tree looking for a specific file. * * @param {String} dir - The directory to start in. * @param {String} file - The filename to locate. * @returns {String} */ function findDir(dir, file) { const { root } = path.parse(dir); let cur = dir; let it; do { it = path.join(cur, file); if (fs.existsSync(it)) { return cur; } cur = path.dirname(cur); } while (cur !== root); } /** * Checks the environment variables to see if telemetry should be disabled. * * @returns {Boolean} */ function isTelemetryDisabled() { return process.env.AXWAY_TELEMETRY_DISABLED === '1' || (!process.env.AXWAY_TEST && ci.isCI); } export { Telemetry as default }; //# sourceMappingURL=telemetry.js.map