Skip to content

Commit 379dbeb

Browse files
feat: anonymous opt-out telemetry (PostHog) (#74)
* feat: anonymous opt-out telemetry (PostHog) Add a thin telemetry layer that counts command usage anonymously. All PostHog-specific code lives behind src/telemetry/index.ts so the backend can be swapped in one file. - Whitelist payload only: machine_id, scaffold_id, command name, mex_version, os, node_version. No args, paths, file contents, repo names, IP, or geo (disableGeoip is set so PostHog never derives location from the IP). - Opt-out precedence: DO_NOT_TRACK=1, MEX_TELEMETRY=0, `mex config set telemetry off`, else on. Dev-repo guard hard-disables when run from a clone of mex itself, before any disk read. - machine_id is a random UUID at ~/.mex/telemetry-id (0600), created only when enabled. scaffold_id (from E1) is passed as a string only — never the identity object — so scaffold_name/origin/upstream can't leak. - Fire-and-forget: capture fires in a preAction hook (so process.exit commands like `check` on drift are still counted); flush is best-effort and bounded; a telemetry failure never blocks or changes a command's exit code. - Transparency: `mex telemetry inspect` prints the exact would-be payload without sending (and without minting the machine-id file); `mex telemetry status` shows enabled/disabled + reason; one-time first-run notice to stderr. - Tests never emit real events (vitest sets MEX_TELEMETRY=0). - Docs: TELEMETRY.md + README link + CHANGELOG entry. * fix(telemetry): address review on #74 - preAction no longer fires for the telemetry/config meta-commands, so `mex telemetry inspect` sends no event and never creates the machine-id file — it stays a pure audit surface even when telemetry is enabled. - flush() now clears its race timer in a finally, so a fast flush can't leave an 800ms timeout pending and delay process exit. - Remove unused `constants` import in global-config.ts. - Fix isDevRepo JSDoc: the bare `mex` package name is intentionally excluded. - Make the "no I/O at import time" test real — it now re-evaluates the module under a fresh HOME and asserts nothing is written, instead of passing vacuously. - TELEMETRY.md: drop the non-interactive auto-disable claim; CI usage is counted (no TTY gate), so the doc matches the code.
1 parent e607a55 commit 379dbeb

11 files changed

Lines changed: 1142 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55
## [Unreleased]
66

77
### Added
8+
- **Anonymous telemetry** — opt-out usage counting via PostHog. Each command sends one event with only `machine_id`, `scaffold_id`, `command` name, `mex_version`, `os`, and `node_version` — no args, paths, file contents, repo names, IP, or location. Opt out with `DO_NOT_TRACK=1`, `MEX_TELEMETRY=0`, or `mex config set telemetry off`. Audit the exact payload with `mex telemetry inspect`; check state with `mex telemetry status`. Telemetry is disabled automatically when running from a clone of the mex repo. See [TELEMETRY.md](TELEMETRY.md).
89
- **Scaffold identity**`.mex/config.json` now carries a stable `scaffold_id` (UUID v4), `scaffold_name`, and nullable `origin`/`upstream`. Generated at `mex setup` and silently backfilled for existing scaffolds on the next CLI invocation. New `getScaffoldIdentity()` export on the public API.
910
- **broken-link drift checker** — flags Markdown links in scaffold files whose local target file does not exist.
1011

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,10 @@ Optional settings live in `.mex/config.json`. Missing values fall back to defaul
262262
}
263263
```
264264

265+
## Telemetry
266+
267+
mex collects anonymous, opt-out usage data (command name, version, OS — never paths, args, file contents, IP, or personal data) to understand how the tool is used. Audit the exact payload with `mex telemetry inspect`, and opt out any time with `DO_NOT_TRACK=1`, `MEX_TELEMETRY=0`, or `mex config set telemetry off`. Full details: [TELEMETRY.md](TELEMETRY.md).
268+
265269
## Ecosystem
266270

267271
mex is provider-neutral. Integration guides, sponsored examples, and community recipes should be useful on their own, clearly labeled, and live in docs rather than silently changing the default experience.

TELEMETRY.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Telemetry
2+
3+
mex collects **anonymous, opt-out** usage data so the maintainer can see how the
4+
tool is actually used — which commands matter, roughly how many people use it,
5+
and whether a project is used by a team. That's the entire purpose. There is no
6+
advertising, no profiling, and no way to tie any data back to a person.
7+
8+
If you'd rather send nothing, see [How to opt out](#how-to-opt-out) — it's one
9+
command or one environment variable.
10+
11+
## What is collected
12+
13+
Every time you run a `mex` command, **one** event is sent containing **exactly**
14+
these fields and nothing else:
15+
16+
| Field | Example | What it is |
17+
|-------|---------|------------|
18+
| `machine_id` | `3f2a…` (random UUID) | A random ID generated once per machine. Not your username, hostname, or anything derived from you. |
19+
| `scaffold_id` | `9b1c…` (random UUID) | A random ID for the mex scaffold (project). Only present when you run inside a scaffold. Lets us tell "one team on one project" apart from "one person on many machines." |
20+
| `command` | `check` | The command **name only** — e.g. `check`, `sync`, `log`. |
21+
| `mex_version` | `0.5.1` | The installed mex version. |
22+
| `os` | `darwin` | The platform string (`darwin` / `linux` / `win32`). |
23+
| `node_version` | `v22.17.1` | The Node.js version. |
24+
25+
You can see the literal payload that would be sent, at any time, without sending
26+
anything:
27+
28+
```bash
29+
mex telemetry inspect
30+
```
31+
32+
## What is NEVER collected
33+
34+
- **No personal data** — no name, email, username, hostname, or git identity.
35+
- **No IP address or location** — geolocation is explicitly disabled.
36+
- **No command arguments, flags, or paths.**
37+
- **No file names or file contents.**
38+
- **No repository name or git remote URL.**
39+
40+
`machine_id` and `scaffold_id` are random UUIDs. They are **not** derived from
41+
your path, repo, email, or anything identifying — they are just random numbers
42+
that let counts be de-duplicated.
43+
44+
## Where the data goes
45+
46+
[PostHog](https://posthog.com) Cloud, **US region** (`https://us.i.posthog.com`).
47+
The ingestion key embedded in mex is write-only — it can send events but cannot
48+
read any data back.
49+
50+
## When telemetry does NOT run
51+
52+
Telemetry is automatically disabled — no event sent, no ID file created — when:
53+
54+
- any opt-out below is active, **or**
55+
- mex is run from a clone of the mex repository itself (so the maintainer's own
56+
development never pollutes the data).
57+
58+
A telemetry failure (offline, firewall, ad-blocker) never blocks, slows, or
59+
changes the exit code of any command. It is fire-and-forget and fully ignored on
60+
error.
61+
62+
## How to opt out
63+
64+
Any **one** of these turns telemetry off completely. When off, no event is ever
65+
sent and the machine-id file is never created.
66+
67+
| Method | Scope |
68+
|--------|-------|
69+
| `DO_NOT_TRACK=1` | The industry-standard env var. Honored everywhere. |
70+
| `MEX_TELEMETRY=0` | mex-specific env var. |
71+
| `mex config set telemetry off` | Persisted in `~/.mex/config.json` (per-machine). Re-enable with `mex config set telemetry on`. |
72+
73+
Check the current state and the active opt-out reason any time:
74+
75+
```bash
76+
mex telemetry status
77+
```
78+
79+
## Files mex writes for telemetry
80+
81+
- `~/.mex/telemetry-id` — your random `machine_id` (mode `0600`). Created only
82+
when telemetry is enabled. Delete it any time; a new one is generated on the
83+
next enabled run.
84+
- `~/.mex/config.json` — your global preferences, including the telemetry
85+
opt-out flag.
86+
87+
These are separate from a project's `.mex/config.json`, which holds the
88+
project's `scaffold_id`.
89+
90+
## A note on PostHog metadata
91+
92+
The PostHog client library attaches two of its own fields to each event —
93+
`$lib` (`posthog-node`) and `$lib_version`. These describe the sending library,
94+
not you, and contain no personal or usage data.

package-lock.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"commander": "^13.1.0",
5757
"glob": "^11.0.1",
5858
"ink": "^7.0.3",
59+
"posthog-node": "^5.21.2",
5960
"react": "^19.2.6",
6061
"remark-frontmatter": "^5.0.0",
6162
"remark-parse": "^11.0.0",

src/cli.ts

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import chalk from "chalk";
22
import { Command, InvalidArgumentError } from "commander";
33
import { realpathSync } from "node:fs";
44
import { pathToFileURL } from "node:url";
5-
import { findConfig, getScaffoldIdentity } from "./config.js";
5+
import { findConfig, getScaffoldIdentity, readScaffoldId } from "./config.js";
66
import { reportConsole, reportQuiet, reportJSON, reportVerbose } from "./reporter.js";
77
import { VERSION } from "./version.js";
8+
import { captureCommand, flush, isEnabled, getPayloadPreview, showFirstRunNotice } from "./telemetry/index.js";
9+
import { readMachineId, setGlobalConfigKey } from "./global-config.js";
810

911
/**
1012
* Load config for a CLI command and backfill scaffold identity on the way.
@@ -41,6 +43,45 @@ async function runTuiCommand(): Promise<void> {
4143
launchTui();
4244
}
4345

46+
// ── Telemetry hooks ──
47+
48+
// preAction: fire the event at the START of the command. Two reasons:
49+
// - the async request gets the whole command runtime to land in the background
50+
// - commands that call process.exit() (e.g. `check` on drift) are still
51+
// counted; a postAction hook would never run after process.exit and would
52+
// systematically miss every error/drift outcome.
53+
// scaffold_id is resolved read-only (never mints). Telemetry never throws here.
54+
program.hook("preAction", (_thisCommand, actionCommand) => {
55+
try {
56+
// Never count the telemetry/config meta-commands. In particular,
57+
// `telemetry inspect` must have zero side effects — no event sent, no
58+
// machine-id file created — so it stays a pure audit surface.
59+
const parentName = actionCommand.parent?.name();
60+
if (parentName === "telemetry" || parentName === "config") return;
61+
62+
let scaffoldId: string | undefined;
63+
try {
64+
scaffoldId = readScaffoldId(findConfig().scaffoldRoot);
65+
} catch {
66+
// No scaffold (or not in one) — omit scaffold_id.
67+
}
68+
captureCommand(actionCommand.name(), scaffoldId);
69+
} catch {
70+
// Telemetry must never affect command behaviour.
71+
}
72+
});
73+
74+
// postAction: best-effort bounded flush for commands that exit naturally.
75+
// Commands that process.exit() skip this, but their event was already sent
76+
// from preAction (flushAt:1 fires the request immediately).
77+
program.hook("postAction", async () => {
78+
try {
79+
await flush();
80+
} catch {
81+
// Telemetry must never affect command behaviour.
82+
}
83+
});
84+
4485
program
4586
.name("mex")
4687
.description("CLI engine for mex scaffold — drift detection, pre-analysis, and targeted sync")
@@ -283,6 +324,74 @@ program
283324
}
284325
});
285326

327+
// ── Telemetry ──
328+
const telemetryCmd = program
329+
.command("telemetry")
330+
.description("Telemetry transparency commands");
331+
332+
telemetryCmd
333+
.command("inspect")
334+
.description("Print the exact JSON payload that would be sent (without sending it)")
335+
.action(() => {
336+
try {
337+
// Read-only: use readScaffoldId (never mints), not getScaffoldIdentity
338+
let scaffoldId: string | undefined;
339+
try {
340+
const config = findConfig();
341+
scaffoldId = readScaffoldId(config.scaffoldRoot);
342+
} catch { /* no scaffold — omit scaffold_id */ }
343+
344+
// Read-only: show the machine_id only if it already exists. Auditing the
345+
// payload must never plant the tracking file on disk.
346+
const machineId = readMachineId();
347+
348+
const payload = getPayloadPreview("inspect", scaffoldId, machineId);
349+
console.log(JSON.stringify(payload, null, 2));
350+
} catch (err) {
351+
console.error((err as Error).message);
352+
process.exit(1);
353+
}
354+
});
355+
356+
telemetryCmd
357+
.command("status")
358+
.description("Show whether telemetry is enabled and the active opt-out reason")
359+
.action(() => {
360+
const result = isEnabled();
361+
if (result.enabled) {
362+
console.log("Telemetry: enabled");
363+
} else {
364+
console.log(`Telemetry: disabled (reason: ${result.reason})`);
365+
}
366+
});
367+
368+
// ── Config ──
369+
const configCmd = program
370+
.command("config")
371+
.description("Manage global mex configuration");
372+
373+
configCmd
374+
.command("set <key> <value>")
375+
.description("Set a global config value (e.g. telemetry on|off)")
376+
.action((key: string, value: string) => {
377+
try {
378+
if (key === "telemetry") {
379+
if (value !== "on" && value !== "off") {
380+
console.error(`Invalid value "${value}" for telemetry. Use "on" or "off".`);
381+
process.exit(1);
382+
}
383+
setGlobalConfigKey("telemetry", value);
384+
console.log(`Telemetry set to "${value}" in ~/.mex/config.json`);
385+
} else {
386+
console.error(`Unknown config key "${key}". Supported keys: telemetry`);
387+
process.exit(1);
388+
}
389+
} catch (err) {
390+
console.error((err as Error).message);
391+
process.exit(1);
392+
}
393+
});
394+
286395
// ── Quick Reference ──
287396
program
288397
.command("commands")
@@ -309,15 +418,23 @@ program
309418
console.log(" mex watch Install post-commit hook for auto drift score");
310419
console.log(" mex watch --interval Run heartbeat every 30 minutes (or config value)");
311420
console.log(" mex watch --uninstall Remove the post-commit hook");
421+
console.log(" mex telemetry inspect Show the exact telemetry payload (without sending)");
422+
console.log(" mex telemetry status Show telemetry enabled/disabled and reason");
423+
console.log(" mex config set <k> <v> Set a global config value (e.g. telemetry off)");
312424
console.log();
313425
console.log(chalk.dim("Not installed globally? Replace 'mex' with 'npx mex-agent'."));
314426
console.log();
315427
});
316428

317429
// Skip auto-parse when imported (e.g. by tests). The bin entry is built by
318-
// tsup as ./dist/cli.js with a shebang banner; only run program.parse() when
319-
// this module is the script being invoked. Resolve argv[1] so symlinked bins
320-
// (npm global, npx, node_modules/.bin) match import.meta.url.
430+
// tsup as ./dist/cli.js with a shebang banner; only run program.parseAsync()
431+
// when this module is the script being invoked. Resolve argv[1] so symlinked
432+
// bins (npm global, npx, node_modules/.bin) match import.meta.url.
433+
//
434+
// Critical: use parseAsync(), not parse(). Commander's sync parse() does not
435+
// await the promise chain built by hooks and async actions — preAction/
436+
// postAction hooks would silently never execute and telemetry events would
437+
// never flush.
321438
let isMainModule = false;
322439
if (process.argv[1]) {
323440
try {
@@ -327,13 +444,18 @@ if (process.argv[1]) {
327444
}
328445
}
329446
if (isMainModule) {
330-
program.parse();
447+
showFirstRunNotice();
448+
program.parseAsync().catch((err: Error) => {
449+
console.error(err.message);
450+
process.exit(1);
451+
});
331452
}
332453

333454
function buildCompletion(shell: string): string {
334455
const commands = [
335456
"setup", "check", "init", "sync", "pattern", "log", "timeline",
336457
"heartbeat", "doctor", "watch", "tui", "commands", "completion",
458+
"telemetry", "config",
337459
];
338460
if (shell === "bash") {
339461
return `_mex_completion() {

src/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,3 +314,15 @@ function findScaffoldRoot(projectRoot: string): string | null {
314314

315315
return null;
316316
}
317+
318+
/**
319+
* Read-only scaffold_id lookup. Returns the scaffold_id string if it exists
320+
* in config.json, or `undefined` if not. **Never mints or writes anything.**
321+
*
322+
* Used by telemetry inspect to show the payload without side-effects.
323+
*/
324+
export function readScaffoldId(scaffoldRoot: string): string | undefined {
325+
const raw = loadPersistedConfig(scaffoldRoot);
326+
const identity = loadScaffoldIdentity(raw);
327+
return identity?.scaffold_id;
328+
}

0 commit comments

Comments
 (0)