mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-28 05:03:09 +08:00
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:
co-authored by
Claude Opus 4.8
Matsuuu
parent
2b6a6a125a
commit
f71a94ddf9
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user