n8n/scripts/dev-metrics/setup.mjs
Charlie Kolb f71a94ddf9
chore: Add opt-in dev-tooling usage metrics (no-changelog) (#33620)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Matsuuu <matias.huhta@n8n.io>
2026-07-15 10:54:41 +00:00

484 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* Consent + install manager for n8n dev-tooling usage metrics.
*
* Approach: replace the tracked binaries (e.g. pnpm) with a shim in place, so
* every invocation — interactive, non-interactive, or from an AI agent — is
* intercepted the same way, with no shell function, rc editing, or PATH-ordering
* dependence. The shim runs the real binary, times it, and reports anonymous
* usage; the original is preserved next to the shim as `<binary>.n8n-real`.
*
* Invoked with no arguments from scripts/prepare.mjs during `pnpm install`: the
* first time an internal developer (git email @n8n.io) installs interactively,
* it asks once (via /dev/tty). The decision persists in ~/.n8n/dev/dev-telemetry.json.
*
* Manual usage:
* node scripts/dev-metrics/setup.mjs bootstrap (prompt once)
* node scripts/dev-metrics/setup.mjs --status show current state
* node scripts/dev-metrics/setup.mjs --enable opt in + install shims
* node scripts/dev-metrics/setup.mjs --disable opt out + restore binaries
* node scripts/dev-metrics/setup.mjs --reset wipe state (for testing)
*
* Nothing here can break `pnpm install`: prepare.mjs invokes it best-effort and
* every failure mode exits cleanly.
*/
import { execFileSync, spawn } from 'node:child_process';
import {
accessSync,
chmodSync,
closeSync,
constants,
existsSync,
mkdirSync,
openSync,
readFileSync,
readSync,
renameSync,
rmSync,
writeFileSync,
writeSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const SHADOW_SHIM_SRC = join(SCRIPT_DIR, 'shadow-shim.sh');
const TRACK_SRC = join(SCRIPT_DIR, 'track.mjs');
const SHIM_MARKER = '# n8n-shadow-shim-version';
const SAVED_SUFFIX = '.n8n-real';
// Binaries to shadow for usage tracking. Add another CLI here — that's it; the
// tracker sends its raw argv, no per-binary code needed.
const SHADOWED_BINARIES = ['pnpm'];
// Dev-metrics state lives under ~/.n8n/dev, namespaced away from n8n's own files.
function devDir() {
const userFolder = process.env.N8N_USER_FOLDER ?? homedir();
return join(userFolder, '.n8n', 'dev');
}
function statePath() {
return join(devDir(), 'dev-telemetry.json');
}
/** Stable copy of the tracker the shim runs — refreshed on each install, so it's
* the latest committed version regardless of which checkout you're in. */
function trackerDest() {
return join(devDir(), 'bin', 'track.mjs');
}
/** Read a `<key>: N` version marker from a file, or null. */
function readVersion(file, key) {
try {
const m = readFileSync(file, 'utf8').match(new RegExp(`${key}:\\s*(\\d+)`));
return m ? Number(m[1]) : null;
} catch {
return null;
}
}
const trackVersion = (file) => readVersion(file, 'n8n-track-version');
function syncTracker() {
const dest = trackerDest();
// Only overwrite when this checkout's tracker is newer (missing/unversioned
// installed copy counts as older), so an older checkout can't downgrade it.
if ((trackVersion(TRACK_SRC) ?? 0) > (trackVersion(dest) ?? -1)) {
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, readFileSync(TRACK_SRC, 'utf8'));
}
return dest;
}
function readState() {
try {
return JSON.parse(readFileSync(statePath(), 'utf8'));
} catch {
return null;
}
}
function writeState(next) {
mkdirSync(devDir(), { recursive: true });
const prev = readState() ?? {};
writeFileSync(
statePath(),
JSON.stringify({ schemaVersion: 1, ...prev, ...next }, null, 2) + '\n',
);
}
function gitEmail() {
try {
return execFileSync('git', ['config', 'user.email'], { encoding: 'utf8' }).trim();
} catch {
return '';
}
}
function isInternalDev() {
return gitEmail().toLowerCase().endsWith('@n8n.io');
}
// --- Binary replacement -----------------------------------------------------
function isExecutable(path) {
try {
accessSync(path, constants.X_OK);
return true;
} catch {
return false;
}
}
function isWritableDir(dir) {
try {
accessSync(dir, constants.W_OK);
return true;
} catch {
return false;
}
}
function isOurShim(path) {
try {
return readFileSync(path, 'utf8').includes(SHIM_MARKER);
} catch {
return false;
}
}
const pathDirs = () => (process.env.PATH ?? '').split(':').filter(Boolean);
/** First executable `bin` on PATH (may be our shim). */
function whichOnPath(bin) {
for (const d of pathDirs()) {
const p = join(d, bin);
if (isExecutable(p)) return p;
}
return '';
}
/** The genuine real binary: skips our shims, following an in-place shim to its saved sibling. */
function resolveRealBinary(bin) {
for (const d of pathDirs()) {
const p = join(d, bin);
if (!isExecutable(p)) continue;
if (!isOurShim(p)) return p; // genuine
const saved = p + SAVED_SUFFIX;
if (isExecutable(saved)) return saved; // in-place shim -> its saved original
// stray shim without a saved original: keep looking for the genuine one
}
return '';
}
const shimVersion = (file) => readVersion(file, 'n8n-shadow-shim-version');
// Escape a value for a POSIX single-quoted shell string: end the quote, emit a
// literal quote via double quotes, reopen — so a path with an apostrophe (e.g.
// /Users/o'brien) can't break out of the assignment in the rendered shim.
const shq = (s) => s.replaceAll("'", `'"'"'`);
/** Render shadow-shim.sh for `bin` and write it (+chmod) to `file`. The template
* wraps each placeholder in single quotes, so every value is escaped for that. */
function renderShim(file, realExec, bin) {
const rendered = readFileSync(SHADOW_SHIM_SRC, 'utf8')
.replaceAll('__N8N_BIN__', shq(bin))
.replaceAll('__N8N_REAL__', shq(realExec))
.replaceAll('__N8N_BINDIR__', shq(dirname(file)))
.replaceAll('__N8N_TRACKER__', shq(trackerDest()));
writeFileSync(file, rendered);
chmodSync(file, 0o755);
}
function installOne(bin) {
const front = whichOnPath(bin);
// Already shimmed: re-render only if this checkout's template is newer
// (missing/unversioned counts as older) — no older checkout can downgrade it.
if (front && isOurShim(front)) {
if ((shimVersion(SHADOW_SHIM_SRC) ?? 0) > (shimVersion(front) ?? -1)) {
const saved = front + SAVED_SUFFIX;
const real = existsSync(saved) ? saved : resolveRealBinary(bin);
if (real) {
const tmp = `${front}.n8n-shim`;
renderShim(tmp, real, bin);
renameSync(tmp, front); // atomic; the old shim stays valid if this throws
}
}
return { bin, action: 'refreshed', path: front };
}
const real = front; // genuine real (first on PATH), or '' if not on PATH
if (!real) return { bin, action: 'missing', path: '' };
const dir = dirname(real);
if (!isWritableDir(dir)) return { bin, action: 'unwritable', path: dir };
// Fresh in-place install. Build the shim beside the real binary first, then
// swap — so a crash never leaves the binary's path empty. If the final swap
// fails after the original was moved aside, roll it back so pnpm keeps working.
// `real` is genuine here (not our shim), so any existing saved sibling is stale
// — e.g. a corepack/pnpm upgrade dropped a fresh binary over the old shim. Move
// the current binary aside unconditionally (overwriting the stale copy) so the
// shim runs today's binary, never a leftover older one.
const saved = real + SAVED_SUFFIX;
const tmp = `${real}.n8n-shim`;
try {
renderShim(tmp, saved, bin); // real is still runnable here
renameSync(real, saved);
renameSync(tmp, real);
} catch {
rmSync(tmp, { force: true });
if (!existsSync(real) && existsSync(saved)) renameSync(saved, real);
return { bin, action: 'error', path: real };
}
return { bin, action: 'in-place', path: real };
}
function installBinaries() {
syncTracker();
const results = SHADOWED_BINARIES.map(installOne);
// Remember where shims landed so --disable/--reset can restore them later even
// from a different node/corepack version whose bin dir isn't on PATH now.
const shims = results.filter((r) => r.action === 'in-place' || r.action === 'refreshed');
if (shims.length) {
const prev = readState()?.installedShims ?? [];
writeState({ installedShims: [...new Set([...prev, ...shims.map((r) => r.path)])] });
}
return results;
}
function uninstallBinaries() {
// Restore shims we recorded (across node versions) plus any on the current PATH.
const recorded = readState()?.installedShims ?? [];
const onPath = pathDirs().flatMap((d) => SHADOWED_BINARIES.map((bin) => join(d, bin)));
const restored = [];
for (const p of new Set([...recorded, ...onPath])) {
if (!isOurShim(p)) continue;
const saved = p + SAVED_SUFFIX;
if (existsSync(saved))
renameSync(saved, p); // restore original
else rmSync(p, { force: true }); // stray shim with no saved original
restored.push(p);
}
return restored;
}
// --- Consent / lifecycle ----------------------------------------------------
/** Fire a one-off lifecycle event via the tracker, detached so setup never waits. */
function fireEvent(event) {
try {
spawn('node', [join(SCRIPT_DIR, 'track.mjs')], {
cwd: SCRIPT_DIR, // inside the repo, so the tracker finds the monorepo root
env: { ...process.env, N8N_DEV_EVENT: event },
detached: true,
stdio: 'ignore',
}).unref();
} catch {
// best-effort; opt-in tracking must never disrupt setup
}
}
function enable() {
const firstOptIn = readState()?.consent !== 'granted';
writeState({ consent: 'granted' });
emit('\n✓ n8n dev metrics enabled. Thanks for helping improve the tooling!\n\n'+
'You can opt out of dev metrics at any time by running "pnpm dev-metrics:reset"\n', GREEN);
for (const r of installBinaries()) {
if (r.action === 'missing') emit(` ${r.bin}: not found on PATH — skipped.`);
else if (r.action === 'unwritable')
emit(` ${r.bin}: ${r.path} not writable — skipped.`);
else if (r.action === 'error')
emit(` ${r.bin}: shim install failed — original left in place.`);
else if (r.action === 'refreshed')
emit(` ${r.bin}: shim already installed (${r.path}).`);
else
emit(
` ${r.bin}: replaced ${r.path} with a shim (original -> ${r.bin}${SAVED_SUFFIX}).`,
);
}
if (firstOptIn) fireEvent('dev:metrics_opt_in');
}
function disable() {
writeState({ consent: 'denied' });
const restored = uninstallBinaries();
emit('✓ n8n dev metrics disabled. Nothing will be sent.', GREEN);
emit(` restored: ${restored.length ? restored.join(', ') : '(nothing was installed)'}`);
}
/**
* Wipe all local state back to genuine first-run (undecided consent): restore
* the binaries and delete the consent/id file. Unlike --disable it records no
* decision, so the next `pnpm install` prompts again.
*/
function reset() {
const restored = uninstallBinaries();
let stateRemoved = false;
try {
rmSync(statePath());
stateRemoved = true;
} catch {
// nothing to remove
}
let trackerRemoved = false;
try {
rmSync(trackerDest());
trackerRemoved = true;
} catch {
// not installed
}
emit('✓ n8n dev metrics reset to first-run state (consent undecided).', GREEN);
emit(` state file: ${stateRemoved ? 'removed' : '(none)'}`);
emit(` tracker: ${trackerRemoved ? 'removed' : '(none)'}`);
emit(` restored: ${restored.length ? restored.join(', ') : '(nothing)'}`);
}
function status() {
const state = readState();
emit(`n8n dev metrics: consent=${state?.consent ?? '(undecided)'}`);
emit(` state file: ${statePath()}`);
emit(
` weekly id: ${state?.anonId ?? '(none yet — assigned on first tracked command)'}${state?.week ? ` (week ${state.week})` : ''}`,
);
emit(` shim src: ${SHADOW_SHIM_SRC} (v${shimVersion(SHADOW_SHIM_SRC) ?? '?'})`);
const td = trackerDest();
emit(
` tracker: ${existsSync(td) ? `${td} (v${trackVersion(td) ?? '?'}, src v${trackVersion(TRACK_SRC) ?? '?'})` : '(not installed)'}`,
);
for (const bin of SHADOWED_BINARIES) {
const front = whichOnPath(bin);
const shimmed = front && isOurShim(front);
emit(
` ${bin}: ${shimmed ? `shimmed @ ${front} (v${shimVersion(front) ?? '?'})` : 'not shimmed'} (real: ${resolveRealBinary(bin) || '?'})`,
);
}
emit(` git email: ${gitEmail() || '(unset)'} (internal: ${isInternalDev()})`);
}
/**
* Synchronous Y/n prompt via the controlling terminal. We read /dev/tty
* directly (not process.stdin) because pnpm pipes lifecycle-script stdio in
* workspaces. A synchronous read avoids leaving a stream handle open that would
* keep this process — and therefore the parent `pnpm install` — from exiting.
*
* The tty is opened non-blocking and polled to a deadline: a terminal that
* exists but never sends input (e.g. a pty a wrapper allocated) times out and
* leaves the decision unmade, instead of hanging `pnpm install` forever.
*/
const PROMPT_TIMEOUT_MS = 30_000;
/** Sleep synchronously without spinning the CPU (to poll the non-blocking tty). */
function sleepMs(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
/** Write a user-facing line to the controlling terminal so it's visible even
* when a parent (pnpm) has captured stdout. Falls back to stdout when there's
* no tty (CI / non-interactive). Mirrors console.log by appending a newline.
* A `color` (ANSI code) is applied only when the destination is a real
* terminal and NO_COLOR isn't set, so codes never leak into captured logs. */
function emit(message, color) {
let fd;
try {
fd = openSync('/dev/tty', constants.O_WRONLY);
writeSync(fd, colorize(message, color, true) + '\n');
} catch {
// no controlling terminal — fall back to stdout (color only if it's a tty)
process.stdout.write(colorize(message, color, process.stdout.isTTY) + '\n');
} finally {
if (fd !== undefined) {
try {
closeSync(fd);
} catch {}
}
}
}
/** Wrap `text` in an ANSI color when the target supports it and NO_COLOR is unset. */
function colorize(text, color, toTerminal) {
if (!color || !toTerminal || process.env.NO_COLOR) return text;
return `${color}${text}${RESET}`;
}
function promptViaTty(message) {
let fd;
try {
fd = openSync('/dev/tty', constants.O_RDWR | constants.O_NONBLOCK);
writeSync(fd, message);
const buf = Buffer.alloc(256);
const deadline = Date.now() + PROMPT_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
const bytes = readSync(fd, buf, 0, buf.length, null);
return buf.toString('utf8', 0, bytes).trim().toLowerCase();
} catch (err) {
if (err.code !== 'EAGAIN') throw err; // real error, not "no input yet"
sleepMs(50); // nothing typed yet — wait and retry until the deadline
}
}
return null; // no answer within the timeout — ask again next time
} catch {
return null; // no controlling terminal (CI / non-interactive), or read failed
} finally {
if (fd !== undefined) {
try {
closeSync(fd);
} catch {}
}
}
}
function bootstrap() {
if (process.env.CI || process.env.DOCKER_BUILD) return;
if (process.env.N8N_DEV_TELEMETRY === '0') return;
const state = readState();
if (state?.consent) {
// Decision made — if granted, re-install (idempotent; self-heals after an
// nvm node switch that left the current node's binary un-shimmed).
if (state.consent === 'granted') installBinaries();
return;
}
if (!isInternalDev()) return; // only internal developers are asked or tracked
const answer = promptViaTty(
'\nn8n collects anonymous usage metrics from internal developers to improve dev tooling.\n' +
'Tip: keep secrets in environment variables, not command arguments.\n\n' +
'Share anonymous dev metrics? [Y/n] ',
);
if (answer === null) return; // no terminal — ask again next time
if (answer === '' || answer === 'y' || answer === 'yes') enable();
else disable();
}
function main() {
const flag = process.argv[2];
if (flag === '--status') return status();
if (flag === '--enable') return enable();
if (flag === '--disable') return disable();
if (flag === '--reset') return reset();
if (flag === '--help' || flag === '-h') {
emit(
'Usage: node scripts/dev-metrics/setup.mjs [--status|--enable|--disable|--reset]\n' +
' --status show consent and per-binary shim status\n' +
' --enable opt in + replace binaries with shims\n' +
' --disable opt out (records denied) + restore binaries\n' +
' --reset wipe all local state back to first-run (for testing)\n' +
' (no args) bootstrap: prompt once for internal developers',
);
return;
}
bootstrap();
}
// Best-effort: never let setup disrupt `pnpm install`.
try {
main();
} catch {}