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>
This commit is contained in:
Charlie Kolb
2026-07-15 10:54:41 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 Matsuuu
parent 2b6a6a125a
commit f71a94ddf9
10 changed files with 1206 additions and 1 deletions
+2
View File
@@ -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
+6
View File
@@ -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
+3
View File
@@ -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",
+188
View File
@@ -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 `<binary>.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 <anything>
└─ 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 `<bin> --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": "<uuid>", "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 `<bin> --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 (`<bin> --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.
+82
View File
@@ -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}`);
});
+483
View File
@@ -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 `<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 {}
+70
View File
@@ -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 <binary>.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
# `<bin> --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
+81
View File
@@ -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"
+278
View File
@@ -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 `<bin> --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(() => {});
+13 -1
View File
@@ -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
}