diff --git a/.agents/skills/conventions/SKILL.md b/.agents/skills/conventions/SKILL.md index 583cc13115b..12a5f26edb4 100644 --- a/.agents/skills/conventions/SKILL.md +++ b/.agents/skills/conventions/SKILL.md @@ -52,6 +52,8 @@ pnpm build > build.log 2>&1 # Always redirect pnpm typecheck # Before commit pnpm lint # Before commit ``` +> Secrets: pnpm command lines may be recorded verbatim (opt-in dev metrics) — +> pass sensitive values via env vars, never inline on the command line. ## Key Packages diff --git a/AGENTS.md b/AGENTS.md index c20b85e2751..9ab9f4c2485 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,12 @@ frontend, and extensible node-based workflow engine. ## General Guidelines - Always use pnpm +- **Secrets on the command line:** if a developer opted into anonymous dev + metrics (`scripts/dev-metrics`), pnpm command arguments are recorded. Arguments + of secret-carrying words (`config`, `login`, `publish`, `token`) — whether a + subcommand or baked into a flag — are dropped, and the home dir is stripped from + paths, but other args are sent as-is — so never put secrets in a command. Pass + sensitive values via environment variables, which are never captured. - When adding comments, keep them concise and to the point - explain the "why" in a line or two; don't be overly verbose. Comments should be scoped and relevant to the surrounding code, not just to the current task diff --git a/package.json b/package.json index 2118b55e695..86fe6c43d7c 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "dev:fe": "run-p start \"dev:fe:editor --filter=@n8n/design-system\"", "dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui", "dev:e2e": "pnpm --filter=n8n-playwright dev --ui", + "dev-metrics:opt-in": "node scripts/dev-metrics/setup.mjs --enable", + "dev-metrics:status": "node scripts/dev-metrics/setup.mjs --status", + "dev-metrics:reset": "node scripts/dev-metrics/setup.mjs --reset", "clean": "turbo run clean", "reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs", "format": "turbo run format && node scripts/format.mjs", diff --git a/scripts/dev-metrics/README.md b/scripts/dev-metrics/README.md new file mode 100644 index 00000000000..476226c46e3 --- /dev/null +++ b/scripts/dev-metrics/README.md @@ -0,0 +1,188 @@ +# Dev-tooling usage metrics + +Opt-in, anonymous telemetry that helps us understand how internal n8n developers +use their CLIs in the monorepo: **which commands are run, how long they take, and +roughly how many developers run them each week.** + +It is deliberately low-friction: no command to remember and no per-run flags. +Internal developers are asked **once** (during `pnpm install`) and the answer is +remembered. Today only `pnpm` is tracked; add another CLI in one line. + +## How it works + +On opt-in we **replace the tracked binary with a shim in place**: the original +is moved next to it as `.n8n-real`, and a shim takes its path. Every +invocation — interactive, non-interactive, or from an AI agent — hits the shim, +which runs the real binary, times it, and reports usage. No shell function, no +rc editing, no PATH-ordering dependence. + +``` +pnpm install + └─ scripts/prepare.mjs + └─ scripts/dev-metrics/setup.mjs ← asks once (git email @n8n.io; prompts via /dev/tty) + └─ on "yes": replace $(command -v pnpm) with a shim, + save the original as pnpm.n8n-real + +pnpm + └─ pnpm shim → runs pnpm.n8n-real, then (backgrounded) → + track.mjs (installed copy in ~/.n8n/dev/bin) → RudderStack (n8n-dev) +``` + +- Replaced **in place**, so it works regardless of PATH order. If the binary's + dir isn't writable it's skipped (rare — corepack pnpm lives in a user-writable + dir). +- `N8N_DEV_SHIM_ACTIVE` guards against double-counting nested calls (e.g. + `turbo -> pnpm`) and the tracker's own ` --version` probe. +- The tracker (`track.mjs`) is **copied to `~/.n8n/dev/bin`** and run from there, so + it's independent of which checkout (or none) you're in. Install only overwrites + the copy when the checkout's `// n8n-track-version` is newer, so the newest + version wins and an older checkout can't downgrade it. It self-scopes: it checks + the monorepo root from the command's cwd, so pnpm runs outside any n8n checkout + send nothing. pnpm prompts via `/dev/tty` because it pipes lifecycle-script stdio. + +| File | Role | +| --- | --- | +| `setup.mjs` | Consent prompt; replaces/restores binaries; `--status`/`--enable`/`--disable`/`--reset`. | +| `shadow-shim.sh` | Shim template (versioned via `# n8n-shadow-shim-version`); rendered per binary with the binary name, saved-real path, and its dir baked in. | +| `track.mjs` | Builds the anonymous event and POSTs it to RudderStack (fire-and-forget). Copied to `~/.n8n/dev/bin` on install; the shim runs that copy. | +| `capture-server.mjs` | Local capture stub for testing — logs every event instead of sending it upstream. | + +State lives in `~/.n8n/dev/dev-telemetry.json` (separate from n8n's secret `config`): + +```json +{ "schemaVersion": 1, "consent": "granted", "anonId": "", "week": "2026-W26" } +``` + +## What is sent + +Event `dev:cli_command` with `anonymousId` = the weekly anonymous id, and: + +| Property | Example | Notes | +| --- | --- | --- | +| `actor` | `human`, `claude-code`, `cursor`, `ci` | Who ran it, inferred from env markers (`CLAUDECODE`, `CURSOR_TRACE_ID`, `CI`/`GITHUB_ACTIONS`); defaults to `human`. | +| `binary` | `pnpm` | The shadowed CLI. | +| `binary_version` | `10.32.1` | The CLI's own version, detected at runtime by the tracker via ` --version` (`null` if unknown). | +| `args` | `["run","build"]`, `["add","left-pad"]` | The command's **argv as an array** (boundaries preserved, incl. quoted/empty args). Parsed/aggregated on the collection side. | +| `dir` | `packages/cli`, `.` | Where it ran, **relative to the repo root** — never an absolute path. | +| `duration_ms` | `41230` | Wall-clock duration. | +| `exit_code` | `0` | The command's exit code. | +| `os` / `arch` | `darwin` / `arm64` | | +| `cpu_cores` / `cpu_model` | `10` / `Apple M2 Pro` | Static machine profile — informs tooling defaults (memory caps, turbo concurrency). | +| `mem_gb` / `mem_free_gb` | `32` / `3.21` | Total RAM class and free RAM at command start (headroom for memory tuning). | +| `os_version` | `macOS 14.6.1`, `Ubuntu 22.04` | Friendly OS version where cheap; kernel release otherwise. | +| `node_version`, `repo_version`, `schema_version` | | For segmenting. | + +**Sent:** the sanitized argv (`args`), plus the repo-relative `dir`, binary + +version, timing, exit code, OS, and a static machine profile (CPU/RAM/OS version — +none of it identifying). **Never sent:** git email, username, absolute paths as a +field (the `dir` is repo-relative). `args` is sanitized before sending: for +the first secret-carrying word (`config`, `login`, `publish`, `token`) — whether a +subcommand or baked into a flag (`--config.//…=SECRET`) — the arg is kept up to the +word plus up to 4 hint chars, and everything after is dropped (e.g. +`["--filter","foo","config"]`, `["install","--config.//r"]`). The home dir is +replaced with `~`. Everything else is sent as-is, so scrub/aggregate on the +collection side too. + +One lifecycle event is also sent: **`dev:metrics_opt_in`**, fired once when a +developer opts in (the transition into `granted`), under the same anonymous +weekly `anonymousId` with only the common properties (os/arch/node/repo/schema). +Opting *out* is deliberately **not** tracked — we don't send telemetry about +someone who just declined it. + +## Privacy & anonymity + +- **Opt-in.** Off until an internal developer accepts the prompt. External + contributors are never prompted and never tracked. +- **Anonymous.** The `anonymousId` is a random UUID that **rotates every ISO + week**, so individuals cannot be followed across weeks. Weekly unique + `anonymousId` counts give "how many developers" without identifying anyone. +- **Scoped.** Only commands run inside an n8n checkout are considered; the + tracker resolves the monorepo root from the command's cwd and sends nothing + otherwise. +- **No IP.** Each event is sent with `context.ip` = `0.0.0.0`, so RudderStack + records no caller IP and does no geo-lookup — the weekly id is the only + identifier. +- **Non-disruptive.** The tracker runs detached with a 2s network timeout and + swallows all errors; it can never slow or fail your command. + +## Tracking another binary + +Add it to `SHADOWED_BINARIES` in **setup.mjs** — that's it: +```js +const SHADOWED_BINARIES = ['pnpm', 'turbo']; +``` +The tracker sends its raw argv like any other binary; no per-binary code needed. + +Existing installs pick up the new binary on the next `pnpm install` (the granted +bootstrap re-runs the install, which is idempotent). The shim itself is versioned +via `# n8n-shadow-shim-version`; shims are re-rendered when their content changes +(version bump or a moved real binary). Each binary's version is detected per +command by the backgrounded tracker (` --version`), so it's always current. + +## Managing it + +```bash +pnpm dev-metrics:opt-in # opt in + replace binaries with shims +pnpm dev-metrics:status # show consent + per-binary shim status +pnpm dev-metrics:reset # restore binaries + wipe state -> first-run +node scripts/dev-metrics/setup.mjs --disable # opt out (records denied) + restore binaries +export N8N_DEV_TELEMETRY=0 # runtime kill switch (no sending) +``` + +Defaults point at the `n8n-dev` RudderStack workspace (its data plane + HTTP +source write key, baked into `track.mjs` — client-side keys, safe to ship). +Override with `N8N_DEV_METRICS_RUDDERSTACK_URL` / `N8N_DEV_METRICS_RUDDERSTACK_KEY` +(e.g. point the URL at the local stub when testing). + +## Testing locally + +`track.mjs` reads its data plane from `N8N_DEV_METRICS_RUDDERSTACK_URL`, so you +can point it at the bundled stub instead of the real one and watch events arrive. + +```bash +# terminal A — start the stub (optionally append raw events to a file) +node scripts/dev-metrics/capture-server.mjs --port 9999 --out /tmp/events.jsonl +``` + +```bash +# terminal B — drive the tracker directly (fastest; run from inside the repo) +U=$(mktemp -d); mkdir -p "$U/.n8n/dev"; echo '{"consent":"granted"}' > "$U/.n8n/dev/dev-telemetry.json" +N8N_USER_FOLDER="$U" \ +N8N_DEV_METRICS_RUDDERSTACK_URL=http://localhost:9999 \ +N8N_DEV_TRACK_BIN=pnpm N8N_DEV_TRACK_MS=1234 N8N_DEV_TRACK_CODE=0 N8N_DEV_TRACK_CWD="$PWD" \ +node scripts/dev-metrics/track.mjs run build # argv after the script = the command's args +``` + +To exercise the **full path** (actually type `pnpm`), point the tracker at the +stub, opt in, run a command, then restore: + +```bash +export N8N_DEV_METRICS_RUDDERSTACK_URL=http://localhost:9999 +node scripts/dev-metrics/setup.mjs --enable # replaces pnpm with the shim in place +pnpm list # → event appears in terminal A (wait ~1s; it's backgrounded) +node scripts/dev-metrics/setup.mjs --reset # restores the real pnpm +``` + +Nothing is sent unless **consent is granted** and the command runs **inside an +n8n checkout**; `N8N_DEV_TELEMETRY=0` disables sending entirely. + +`sh scripts/dev-metrics/test/selfcheck.sh` runs the whole enable→run→reset flow +against a fake binary in a temp dir (never touches your real pnpm). + +## Querying + +Once the `n8n-dev` RudderStack source is wired to a destination (warehouse / +analytics tool), query the `dev:cli_command` events there: + +- **Most-used commands:** derive a subcommand from `args` (e.g. its first token) + and group by it, optionally filtered by `binary`. +- **How many developers:** unique `anonymousId` per ISO week. +- **Opt-ins:** unique `anonymousId` on `dev:metrics_opt_in`. +- **Human vs AI agent:** group any of the above by `actor`. +- **Which packages:** group by `dir`. +- **How long commands take:** p50/p90 of `duration_ms`, grouped by the derived + subcommand. +- **Fleet profile:** distribution of `mem_gb` / `cpu_cores`, Apple-Silicon vs + Intel vs Linux split via `cpu_model`/`arch`, OS versions via `os_version` — to + tune tooling defaults (e.g. `pnpm agent:setup` memory caps and concurrency) + against the machines devs actually run. diff --git a/scripts/dev-metrics/capture-server.mjs b/scripts/dev-metrics/capture-server.mjs new file mode 100644 index 00000000000..ef435a64e53 --- /dev/null +++ b/scripts/dev-metrics/capture-server.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Local capture stub for testing dev-metrics events. + * + * Stands in for the RudderStack data plane so you can see exactly what track.mjs + * would send, without touching the real workspace. It accepts any POST (e.g. + * `/v1/track`), pretty-prints the event, and replies `{"status":1}`. + * + * Usage: + * node scripts/dev-metrics/capture-server.mjs [--port 9999] [--out events.jsonl] + * + * Then point the tracker at it (in the shell that runs pnpm): + * export N8N_DEV_METRICS_RUDDERSTACK_URL=http://localhost:9999 + * + * Reminder: track.mjs only sends when consent is granted + * (~/.n8n/dev/dev-telemetry.json) and when run from inside an n8n checkout. See the + * "Testing locally" section of this folder's README. + */ +import { appendFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { parseArgs } from 'node:util'; + +let values; +try { + ({ values } = parseArgs({ + options: { + port: { type: 'string', default: '9999', short: 'p' }, + out: { type: 'string' }, + help: { type: 'boolean', default: false, short: 'h' }, + }, + strict: true, + })); +} catch (err) { + process.stderr.write(`capture-server: ${err.message}\n`); + process.exit(2); +} + +if (values.help) { + process.stdout.write( + 'Usage: node scripts/dev-metrics/capture-server.mjs [--port 9999] [--out events.jsonl]\n', + ); + process.exit(0); +} + +const port = Number(values.port); +if (!Number.isInteger(port) || port <= 0 || port > 65535) { + process.stderr.write('capture-server: --port must be a valid TCP port\n'); + process.exit(2); +} + +let count = 0; + +function logEvent(url, e) { + count += 1; + console.log( + `\n#${count} [${new Date().toISOString()}] POST ${url} ${e.event ?? '(no event)'} id=${e.anonymousId}`, + ); + console.log( + Object.entries(e.properties ?? {}) + .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`) + .join('\n'), + ); + if (values.out) appendFileSync(values.out, JSON.stringify({ url, ...e }) + '\n'); +} + +createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + try { + logEvent(req.url, JSON.parse(body)); + } catch { + console.log(`\n[${new Date().toISOString()}] POST ${req.url} (non-JSON body)\n ${body}`); + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"status":1}'); + }); +}).listen(port, () => { + console.log(`Capture stub listening on http://localhost:${port}`); + console.log(` export N8N_DEV_METRICS_RUDDERSTACK_URL=http://localhost:${port}`); + if (values.out) console.log(`Appending raw events to ${values.out}`); +}); diff --git a/scripts/dev-metrics/setup.mjs b/scripts/dev-metrics/setup.mjs new file mode 100644 index 00000000000..12f7e936313 --- /dev/null +++ b/scripts/dev-metrics/setup.mjs @@ -0,0 +1,483 @@ +#!/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 `.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 `: 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 {} diff --git a/scripts/dev-metrics/shadow-shim.sh b/scripts/dev-metrics/shadow-shim.sh new file mode 100644 index 00000000000..5767d6d02bf --- /dev/null +++ b/scripts/dev-metrics/shadow-shim.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# n8n-shadow-shim-version: 1 +# +# n8n dev metrics — binary shim (template). setup.mjs renders this per shadowed +# CLI and installs it in place of the real binary (the original is saved next to +# it as .n8n-real), filling in the binary name, the saved real binary, +# and its directory. Because it replaces the binary itself, ANY invocation hits +# it — interactive, non-interactive, and AI agents alike. It always runs the real +# binary, never changes its exit code, and reports usage in the background. +# +# Tracking is scoped to n8n checkouts and gated on consent (both in track.mjs). +# N8N_DEV_SHIM_ACTIVE prevents nested calls (turbo -> pnpm, or the tracker's own +# ` --version` probe) from being counted twice. The real binary is baked in +# (not derived from $0), so the shim can never re-invoke itself. + +__bin='__N8N_BIN__' +__real='__N8N_REAL__' +__bindir='__N8N_BINDIR__' +__tracker='__N8N_TRACKER__' # installed copy of track.mjs, refreshed on each install + +# If the baked real binary moved (e.g. corepack/pnpm upgrade), re-resolve it via +# PATH with our own directory removed — never resolving back to this shim. +if [ ! -x "$__real" ]; then + __cp=$(printf '%s' "${PATH:-}" | tr ':' '\n' | grep -vxF "$__bindir" | paste -sd: -) + __real=$(PATH="$__cp" command -v "$__bin" 2>/dev/null) + [ -n "$__real" ] || exec env PATH="$__cp" "$__bin" "$@" # last resort, no loop +fi + +# Nested call: run the real binary without tracking, so it isn't counted twice. +if [ -n "${N8N_DEV_SHIM_ACTIVE:-}" ]; then + exec "$__real" "$@" +fi + +# Millisecond clock. GNU date (Linux) has %N; macOS /bin/sh lacks it, so fall +# back to perl's Time::HiRes (preinstalled on macOS). Whole seconds only if +# neither exists — ponytail: add gdate/EPOCHREALTIME if such a box ever appears. +__now_ms() { + __t=$(date +%s%N 2>/dev/null) + case "$__t" in + '' | *[!0-9]*) ;; # not GNU date — fall through + *) echo $(( __t / 1000000 )); return ;; + esac + if command -v perl >/dev/null 2>&1; then + __t=$(perl -MTime::HiRes -e 'printf "%d", Time::HiRes::time()*1000' 2>/dev/null) + [ -n "$__t" ] && { echo "$__t"; return; } + fi + echo $(( $(date +%s) * 1000 )) +} + +__start=$(__now_ms) +N8N_DEV_SHIM_ACTIVE=1 "$__real" "$@" +__code=$? +__end=$(__now_ms) + +# Run the installed tracker (a stable copy in ~/.n8n/dev, not the checkout's) — it +# self-scopes to n8n checkouts, so we just hand it the cwd and let it decide. +if [ -f "$__tracker" ] && command -v node >/dev/null 2>&1; then + # Background in a subshell so no "[job] PID" notice reaches the terminal. + # Pass argv through as the tracker's own arguments ("$@", not "$*") so quoted + # and empty args keep their boundaries. + ( + N8N_DEV_TRACK_BIN="$__bin" \ + N8N_DEV_TRACK_MS="$(( __end - __start ))" \ + N8N_DEV_TRACK_CODE="$__code" \ + N8N_DEV_TRACK_CWD="$PWD" \ + nohup node "$__tracker" "$@" >/dev/null 2>&1 & + ) +fi + +exit $__code diff --git a/scripts/dev-metrics/test/selfcheck.sh b/scripts/dev-metrics/test/selfcheck.sh new file mode 100644 index 00000000000..9a63f02d414 --- /dev/null +++ b/scripts/dev-metrics/test/selfcheck.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# Self-check for the binary-replacement flow (setup.mjs + shadow-shim.sh), +# against a fake binary in a temp dir — never touches your real pnpm. +# sh scripts/dev-metrics/test/selfcheck.sh +set -e +SELF=$(cd "$(dirname "$0")" && pwd) +SRC=$(cd "$SELF/.." && pwd) # scripts/dev-metrics (holds setup.mjs etc.) +REPO=$(cd "$SELF/../../.." && pwd) +PORT=9944 +EV=$(mktemp) + +node "$SRC/capture-server.mjs" --port "$PORT" --out "$EV" >/dev/null 2>&1 & +SRV=$! +trap 'kill $SRV 2>/dev/null' EXIT +sleep 0.4 + +# Apostrophes in both paths exercise shell escaping of the rendered shim: an +# unescaped value would make the shim a syntax error and fail the assertions below. +T=$(mktemp -d); UF="$T/uf-o'brien"; BIN="$T/bin-o'brien" +mkdir -p "$UF" "$BIN" +printf '#!/bin/sh\ncase "$1" in --version) echo 9.9.9; exit 0;; esac\necho "REAL $*"\nexit 7\n' > "$BIN/pnpm" +chmod +x "$BIN/pnpm" +export N8N_USER_FOLDER="$UF" N8N_DEV_METRICS_RUDDERSTACK_URL="http://localhost:$PORT" PATH="$BIN:$PATH" + +fail() { echo "FAIL: $1"; exit 1; } + +node "$SRC/setup.mjs" --enable >/dev/null +grep -q "n8n-shadow-shim-version" "$BIN/pnpm" || fail "shim not installed" +grep -q "REAL" "$BIN/pnpm.n8n-real" || fail "original not saved" + +cd "$REPO" +rc=0; out=$(sh -c "pnpm build \"$HOME/secretpath\"" 2>&1) || rc=$? +[ "$rc" -eq 7 ] || fail "exit code not preserved (got $rc)" +echo "$out" | grep -q "REAL build $HOME/secretpath" || fail "real binary did not run" + +N8N_DEV_SHIM_ACTIVE=1 sh -c 'pnpm test' >/dev/null 2>&1 || true # must not track + +# A sensitive subcommand: args up to it are kept, everything after is redacted. +sh -c 'pnpm --filter foo config set //registry.npmjs.org/:_authToken supersecrettoken' >/dev/null 2>&1 || true + +# A secret baked into a flag (typo): keep only the flag prefix, drop the value. +sh -c 'pnpm install --config.//registry.npmjs.org/:_authToken=typosecrettoken' >/dev/null 2>&1 || true + +node "$SRC/setup.mjs" --enable >/dev/null # idempotent +[ -e "$BIN/pnpm.n8n-real.n8n-real" ] && fail "double-saved on re-enable" + +sleep 1 +n=$(grep -c '"event":"dev:cli_command"' "$EV" || true) +[ "$n" -eq 3 ] || fail "expected 3 events, got $n" +# Home dir stripped to ~; path arg otherwise sent whole (no truncation). +grep -qF '"args":["build","~/secretpath"]' "$EV" || fail "home dir not stripped from args" +# Args up to the subcommand kept, everything after it dropped (secret never seen). +grep -qF '"args":["--filter","foo","config"]' "$EV" || fail "sensitive subcommand not redacted" +grep -qF 'supersecrettoken' "$EV" && fail "secret token leaked into event" +# Inline flag secret: prefix + 4-char hint (".//r") kept, value dropped. +grep -qF '"args":["install","--config.//r"]' "$EV" || fail "inline flag secret not redacted" +grep -qF 'typosecrettoken' "$EV" && fail "inline secret leaked into event" +grep -q '"binary_version":"9.9.9"' "$EV" || fail "version not detected" +grep -q '"cpu_cores"' "$EV" || fail "machine info not captured" + +node "$SRC/setup.mjs" --reset >/dev/null +grep -q "REAL" "$BIN/pnpm" || fail "original not restored" +grep -q "n8n-shadow-shim-version" "$BIN/pnpm" && fail "shim left after reset" +[ -e "$BIN/pnpm.n8n-real" ] && fail ".n8n-real left after reset" + +# Regression: a package-manager upgrade (a fresh binary dropped over the shim, +# with the previous .n8n-real left behind) must re-shim the NEW binary — not +# silently run the stale sibling and downgrade the developer. +( + B2="$T/bin2-o'brien"; mkdir -p "$B2" + printf '#!/bin/sh\necho "OLD $*"\n' > "$B2/pnpm"; chmod +x "$B2/pnpm" + export N8N_USER_FOLDER="$T/uf2" N8N_DEV_TELEMETRY=0 PATH="$B2:$PATH" + node "$SRC/setup.mjs" --enable >/dev/null + printf '#!/bin/sh\necho "NEW $*"\n' > "$B2/pnpm"; chmod +x "$B2/pnpm" # simulate upgrade + node "$SRC/setup.mjs" --enable >/dev/null + out=$(pnpm probe 2>&1) || true + echo "$out" | grep -q "NEW probe" || fail "upgrade downgraded via stale .n8n-real" +) + +rm -rf "$T" "$EV" +echo "ALL PASS" diff --git a/scripts/dev-metrics/track.mjs b/scripts/dev-metrics/track.mjs new file mode 100644 index 00000000000..bf59ec614b1 --- /dev/null +++ b/scripts/dev-metrics/track.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * Anonymous dev-tooling usage tracker for internal n8n developers. + * + * Invoked fire-and-forget (backgrounded) by the shim (shadow-shim.sh) that + * replaces each tracked binary, after every shadowed command run inside an n8n + * checkout. It records the binary, its raw argv, wall-clock duration and exit + * code, plus a static machine profile (CPU/RAM/OS), to the `n8n-dev` RudderStack + * workspace under a weekly-rotating anonymous id, so we can see which commands + * are used, how long they take, roughly how many developers run them each week, + * and the specs of the machines they build on. + * + * Today only `pnpm` is shadowed; add another CLI to SHADOWED_BINARIES in setup.mjs. + * + * Nothing is sent unless the developer granted consent via + * scripts/dev-metrics/setup.mjs (stored in ~/.n8n/dev/dev-telemetry.json). + * + * Input from the shim: the command's argv as this script's own arguments, plus + * N8N_DEV_TRACK_BIN the shadowed binary, e.g. "pnpm" + * N8N_DEV_TRACK_MS wall-clock duration in ms + * N8N_DEV_TRACK_CODE exit code + * N8N_DEV_TRACK_CWD directory the command ran in + * + * The argv is sent as `args` (an array, boundaries preserved) after sanitizing: + * on the first secret-carrying word (`config`, `login`, …) — a subcommand or an + * inline flag like `--config.x=SECRET` — the arg is kept up to the word plus a + * short hint and everything after is dropped. The home dir is replaced with `~` + * so paths don't de-anonymize the developer. + * `dir` is repo-relative. Errors are swallowed so tracking never disrupts a workflow. + */ +// n8n-track-version: 1 — bump on change; setup.mjs never downgrades the installed copy. +import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { cpus, freemem, homedir, release, totalmem } from 'node:os'; +import { dirname, join, parse, relative } from 'node:path'; + +// Telemetry goes to the `n8n-dev` RudderStack workspace via its HTTP tracking +// API. Defaults are that workspace's data plane + HTTP source write key — like +// n8n's product keys these are client-side and safe to ship; override via env. +// Source resourceId: 3GX55bj9H9f9KpUG8AgMJlfymnf +const RUDDERSTACK_URL = + process.env.N8N_DEV_METRICS_RUDDERSTACK_URL ?? 'https://nnrry.dataplane.rudderstack.com'; +const RUDDERSTACK_KEY = + process.env.N8N_DEV_METRICS_RUDDERSTACK_KEY ?? '3GX55Y0O8vJnXnesPItW1BWffbN'; +const EVENT_NAME = 'dev:cli_command'; +const SCHEMA_VERSION = 1; +const POST_TIMEOUT_MS = 2000; + +/** Walk up from `start` to the n8n monorepo root (package.json name === n8n-monorepo). */ +function findMonorepoRoot(start) { + let dir = start; + const { root } = parse(dir); + while (dir && dir !== root) { + const pkgPath = join(dir, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + if (pkg?.name === 'n8n-monorepo') return { dir, pkg }; + } catch { + // ignore unreadable/invalid package.json and keep walking up + } + } + dir = dirname(dir); + } + return null; +} + +/** Detect the binary's version by running ` --version`; null on failure. */ +function detectBinaryVersion(bin) { + try { + const out = execFileSync(bin, ['--version'], { + encoding: 'utf8', + timeout: 3000, + stdio: ['ignore', 'pipe', 'ignore'], + // Mark as active so this probe passes straight through the shim + // instead of triggering another tracked invocation. + env: { ...process.env, N8N_DEV_SHIM_ACTIVE: '1' }, + }); + const m = out.match(/\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?/); + return m && m[0].length <= 40 ? m[0] : null; + } catch { + return null; + } +} + +// --- Weekly anonymous id ---------------------------------------------------- + +/** ISO-8601 week label, e.g. "2026-W26". Used to rotate the anonymous id weekly. */ +function isoWeek(date) { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const dayNum = (d.getUTCDay() + 6) % 7; // Mon=0 .. Sun=6 + d.setUTCDate(d.getUTCDate() - dayNum + 3); // shift to the Thursday of this week + const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4)); + const firstDayNum = (firstThursday.getUTCDay() + 6) % 7; + firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3); + const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 24 * 3600 * 1000)); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`; +} + +function statePath() { + const userFolder = process.env.N8N_USER_FOLDER ?? homedir(); + return join(userFolder, '.n8n', 'dev', 'dev-telemetry.json'); +} + +function readState() { + try { + return JSON.parse(readFileSync(statePath(), 'utf8')); + } catch { + return null; + } +} + +/** Ensure the anonymous id matches the current week; rotate (and persist) if not. */ +function currentAnonId(state) { + const week = isoWeek(new Date()); + if (state.week === week && typeof state.anonId === 'string') return state.anonId; + const anonId = randomUUID(); + try { + writeFileSync(statePath(), JSON.stringify({ ...state, anonId, week }, null, 2) + '\n'); + } catch { + // best-effort; if we cannot persist we still send under the fresh id + } + return anonId; +} + +// --- Send ------------------------------------------------------------------- + +/** + * Classify who ran the command from well-known agent/CI env markers, so human + * and AI-agent usage can be told apart. Defaults to "human". + */ +function detectActor() { + if (process.env.CLAUDECODE) return 'claude-code'; + if (process.env.CURSOR_TRACE_ID) return 'cursor'; + if (process.env.GITHUB_ACTIONS || process.env.CI) return 'ci'; + return 'human'; +} + +/** Human-friendly OS version where it's cheap; kernel release otherwise. */ +function osVersion() { + try { + if (process.platform === 'darwin') { + // ponytail: one tiny spawn for the marketing version; drop to release() if it ever matters. + const v = execFileSync('sw_vers', ['-productVersion'], { encoding: 'utf8', timeout: 1000 }); + return `macOS ${v.trim()}`; + } + if (process.platform === 'linux') { + const m = readFileSync('/etc/os-release', 'utf8').match(/^PRETTY_NAME="?(.+?)"?$/m); + if (m) return m[1]; + } + } catch { + // fall through to the kernel release + } + return release(); +} + +/** Static machine profile (hardware + OS), for segmenting usage by the fleet's specs. */ +function machineInfo() { + const cores = cpus(); + const gb = (bytes) => Math.round((bytes / 1024 ** 3) * 100) / 100; + return { + cpu_cores: cores.length || null, + cpu_model: cores[0]?.model?.trim().slice(0, 64) ?? null, + mem_gb: Math.round(totalmem() / 1024 ** 3), // total RAM class (8/16/32…) + mem_free_gb: gb(freemem()), // headroom at command start — the memory-cap signal + os_version: osVersion(), + }; +} + +async function sendEvent(event, anonymousId, properties) { + await fetch(`${RUDDERSTACK_URL}/v1/track`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // RudderStack HTTP API: HTTP Basic auth, username = source write key. + Authorization: `Basic ${Buffer.from(`${RUDDERSTACK_KEY}:`).toString('base64')}`, + }, + // context.ip "0.0.0.0" tells RudderStack not to record the caller's IP + // (or geo-locate from it) — the id is meant to be the only identifier. + body: JSON.stringify({ + type: 'track', + event, + anonymousId, + properties, + context: { ip: '0.0.0.0' }, + }), + signal: AbortSignal.timeout(POST_TIMEOUT_MS), + }); +} + +// Words whose arguments/values can carry secrets (registry tokens, auth config). +// Matched as a prefix (leading dashes ignored) so both the positional subcommand +// `pnpm config set …` and the inline flag `pnpm i --config.//…=SECRET` are caught. +const REDACTED_SUBCOMMANDS = ['config', 'login', 'publish', 'token']; + +// Chars kept after a sensitive word as a hint of what follows. A short secret +// prefix can leak (e.g. `--token=abc…`) — accepted for the diagnostic hint. +const HINT_CHARS = 4; + +/** Sanitize argv before sending. On the first arg containing a sensitive word + * (prefix match, leading dashes ignored) — whether a positional subcommand + * `config` or an inline flag `--config.x=SECRET` — keep that arg up to the word + * plus up to HINT_CHARS more, then drop everything after. Also replace the home + * dir with `~` so absolute paths don't identify the user. + * ponytail: prefix match over-redacts an arg merely starting with a word (e.g. a + * `configure` script) — losing those args is the safe failure. */ +function sanitizeArgs(args) { + const home = homedir(); + const strip = (a) => (home ? a.replaceAll(home, '~') : a); + const out = []; + for (const arg of args) { + const bare = arg.replace(/^-+/, ''); // ignore leading dashes when matching + const word = REDACTED_SUBCOMMANDS.find((w) => bare.startsWith(w)); + if (!word) { + out.push(strip(arg)); + continue; + } + const end = arg.length - bare.length + word.length; // through the matched word + out.push(arg.slice(0, end + HINT_CHARS)); + return out; // stop — drop everything after the sensitive word + } + return out; +} + +async function main() { + if (process.env.N8N_DEV_TELEMETRY === '0') return; // runtime kill switch + + const cwd = process.env.N8N_DEV_TRACK_CWD ?? process.cwd(); + const repo = findMonorepoRoot(cwd); + if (!repo) return; // only track commands run inside an n8n checkout + + const state = readState(); + if (state?.consent !== 'granted') return; // no consent → send nothing + + // Lifecycle events (e.g. opt-in) fired by setup.mjs reuse this sender. They + // carry only the common, anonymous properties — no command/binary/duration. + const customEvent = process.env.N8N_DEV_EVENT; + if (customEvent) { + if (!/^dev:[a-z_]+$/.test(customEvent)) return; // only our own event names + await sendEvent(customEvent, currentAnonId(state), { + actor: detectActor(), + os: process.platform, + arch: process.arch, + node_version: process.versions.node, + ...machineInfo(), + repo_version: repo.pkg?.version ?? null, + schema_version: SCHEMA_VERSION, + }); + return; + } + + const binary = process.env.N8N_DEV_TRACK_BIN || 'pnpm'; + const binaryVersion = detectBinaryVersion(binary); + const durationMs = Number.parseInt(process.env.N8N_DEV_TRACK_MS ?? '', 10); + const exitCode = Number.parseInt(process.env.N8N_DEV_TRACK_CODE ?? '', 10); + // Repo-relative dir (e.g. "packages/cli", "." at root) — never an absolute path. + const dir = relative(repo.dir, cwd) || '.'; + + await sendEvent(EVENT_NAME, currentAnonId(state), { + actor: detectActor(), + binary, + binary_version: binaryVersion, + args: sanitizeArgs(process.argv.slice(2)), // redacted/home-stripped (sanitizeArgs) + dir, + duration_ms: Number.isFinite(durationMs) ? durationMs : null, + exit_code: Number.isFinite(exitCode) ? exitCode : null, + os: process.platform, + arch: process.arch, + node_version: process.versions.node, + ...machineInfo(), + repo_version: repo.pkg?.version ?? null, + schema_version: SCHEMA_VERSION, + }); +} + +// Never let tracking surface an error or a non-zero exit to the developer. +main().catch(() => {}); diff --git a/scripts/prepare.mjs b/scripts/prepare.mjs index 2ad91f77676..16cc1f4de00 100644 --- a/scripts/prepare.mjs +++ b/scripts/prepare.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; +import { resolve } from 'node:path'; // Skip lefthook install in CI or Docker build if (process.env.CI || process.env.DOCKER_BUILD) { @@ -8,3 +9,14 @@ if (process.env.CI || process.env.DOCKER_BUILD) { } execSync('pnpm lefthook install', { stdio: 'inherit' }); + +// Opt-in anonymous dev-tooling metrics (internal developers only). Best-effort: +// must never break `pnpm install`. +try { + // execFileSync (no shell) so a checkout path with spaces still works. + execFileSync('node', [resolve(import.meta.dirname, 'dev-metrics', 'setup.mjs')], { + stdio: 'inherit', + }); +} catch { + // ignore — metrics setup is non-essential +}