Skip to content

Commit 32a913e

Browse files
PunGrumpyclaude
andauthored
feat(cli): add telemetry (#22)
* feat(cli): add telemetry helper * feat(cli): track command usage * feat(cli): show telemetry notice on startup * chore(changeset): add cli telemetry patch note * fix(cli): update telemetry tracking to use await for event tracking * fix(cli): clarify telemetry collects git identity, not anonymous data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 422bd4e commit 32a913e

9 files changed

Lines changed: 182 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@vernostudio/cli": patch
3+
---
4+
5+
Add usage tracking with PostHog for core CLI commands and show an opt-out notice on startup. Telemetry collects git identity (email and name) when available, falling back to a persistent anonymous UUID. Opt out by setting `DO_NOT_TRACK=1` or `VERNO_TELEMETRY_DISABLED=1`.

bun.lock

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

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@clack/prompts": "^1.4.0",
5656
"commander": "^14.0.3",
5757
"picocolors": "^1.1.1",
58+
"posthog-node": "^4.8.1",
5859
"semver": "^7.8.1"
5960
},
6061
"devDependencies": {

packages/cli/src/analytics.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { execSync } from "node:child_process";
2+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3+
import { homedir } from "node:os";
4+
import { join } from "node:path";
5+
import { PostHog } from "posthog-node";
6+
import packageJson from "../package.json";
7+
8+
const POSTHOG_API_KEY = "phc_uKMUhqYc5TLZ7NPNPnY3Bdnd29HKZ9du7BQepwsm8Wn";
9+
const GIT_EXEC_OPTIONS = { encoding: "utf-8" as const, stdio: "pipe" as const };
10+
const ANON_ID_PATH = join(homedir(), ".config", "verno", "anonymous-id");
11+
12+
export const isTelemetryEnabled = (): boolean =>
13+
process.env["DO_NOT_TRACK"] !== "1" && process.env["VERNO_TELEMETRY_DISABLED"] !== "1";
14+
15+
interface GitIdentity {
16+
distinctId: string;
17+
email?: string;
18+
name?: string;
19+
}
20+
21+
const getAnonymousId = (): string => {
22+
try {
23+
return readFileSync(ANON_ID_PATH, "utf-8").trim();
24+
} catch {
25+
const id = crypto.randomUUID();
26+
try {
27+
mkdirSync(join(homedir(), ".config", "verno"), { recursive: true });
28+
writeFileSync(ANON_ID_PATH, id, "utf-8");
29+
} catch {
30+
// read-only fs — use the id for this run only
31+
}
32+
return id;
33+
}
34+
};
35+
36+
const getGitIdentity = (): GitIdentity => {
37+
try {
38+
const email = execSync("git config user.email", GIT_EXEC_OPTIONS).trim();
39+
if (email) {
40+
const name = execSync("git config user.name", GIT_EXEC_OPTIONS).trim();
41+
return { distinctId: email, email, name: name || undefined };
42+
}
43+
} catch {
44+
// git not available or not configured
45+
}
46+
return { distinctId: getAnonymousId() };
47+
};
48+
49+
export const trackEvent = async (
50+
event: string,
51+
properties: Record<string, unknown> = {},
52+
): Promise<void> => {
53+
if (!isTelemetryEnabled()) {
54+
return;
55+
}
56+
try {
57+
const { distinctId, name, email } = getGitIdentity();
58+
const client = new PostHog(POSTHOG_API_KEY, {
59+
flushAt: 1,
60+
flushInterval: 0,
61+
host: "https://us.i.posthog.com",
62+
});
63+
if (email) {
64+
client.identify({
65+
distinctId,
66+
properties: { email, name },
67+
});
68+
}
69+
client.capture({
70+
distinctId,
71+
event,
72+
properties: {
73+
...properties,
74+
cli_version: packageJson.version,
75+
node_version: process.version,
76+
platform: process.platform,
77+
},
78+
});
79+
await client.shutdown();
80+
} catch {
81+
// silent — analytics must never break the CLI
82+
}
83+
};

packages/cli/src/commands/create/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { getNextStepHints } from "./next-steps";
2323
import { buildCreatePlan, getPlanSummary } from "./plan";
2424
import { printDoneNextSteps, printStepPlanDryRun } from "../shared/command-ui";
2525
import { runInstallTask, runPostSetupPipeline } from "../shared/post-setup-pipeline";
26+
import { trackEvent } from "../../analytics";
2627

2728
const resolveInputs = async (args: {
2829
name?: string;
@@ -142,5 +143,17 @@ export const runCreate = async (args: {
142143
throw error;
143144
}
144145

146+
await trackEvent("create_project", {
147+
addons: resolved.addons,
148+
dry_run: false,
149+
frontend: resolved.frontend,
150+
linter: resolved.ultraciteLinter,
151+
package_manager: resolved.packageManager,
152+
shadcn_preset: resolved.shadcnPreset,
153+
skip_git: !resolved.doGit,
154+
skip_install: !resolved.doInstall,
155+
ui: resolved.ui,
156+
});
157+
145158
printDoneNextSteps(`Project "${resolved.name}" is ready.`, nextSteps);
146159
};

packages/cli/src/commands/doctor/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { runFullAudit } from "./audit";
44
import { applyFixes } from "./fix";
55
import { resolveDoctorInputs } from "./args";
66
import type { DoctorCommandOptions } from "./args";
7+
import { trackEvent } from "../../analytics";
78

89
const getSymbol = (severity: "ok" | "warning" | "error"): string => {
910
if (severity === "error") {
@@ -59,6 +60,11 @@ export const runDoctor = async (args: {
5960
const totalIssues = errors.length + warnings.length;
6061

6162
if (totalIssues === 0) {
63+
await trackEvent("doctor_run", {
64+
fix: resolved.fix,
65+
issues_found: 0,
66+
package_manager: resolved.packageManager,
67+
});
6268
p.outro(pc.green("All checks passed! Your Verno project is healthy."));
6369
process.exitCode = 0;
6470
return;
@@ -131,6 +137,11 @@ export const runDoctor = async (args: {
131137
}
132138

133139
// If we got here, issues remain and were not fixed
140+
await trackEvent("doctor_run", {
141+
fix: resolved.fix,
142+
issues_found: totalIssues,
143+
package_manager: resolved.packageManager,
144+
});
134145
p.outro(
135146
pc.red(
136147
`Audit complete. Found ${String(errors.length)} error(s) and ${String(warnings.length)} warning(s).`,

0 commit comments

Comments
 (0)