Skip to content

Commit 21b6702

Browse files
committed
feat: add anonymous CLI telemetry via PostHog
Fire-and-forget usage tracking (command name, OS, arch, CLI version). No PII collected. Opt out with DO_NOT_TRACK=1 or RESEND_TELEMETRY_DISABLED=1. Anonymous ID stored at ~/.config/resend/telemetry-id.
1 parent ea69cd8 commit 21b6702

4 files changed

Lines changed: 270 additions & 0 deletions

File tree

src/cli.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ import { updateCommand } from './commands/update';
2020
import { webhooksCommand } from './commands/webhooks/index';
2121
import { whoamiCommand } from './commands/whoami';
2222
import { errorMessage, outputError } from './lib/output';
23+
import { trackCommand } from './lib/telemetry';
2324
import { checkForUpdates } from './lib/update-check';
2425
import { PACKAGE_NAME, VERSION } from './lib/version';
2526

27+
let lastCommandName = '';
28+
2629
const program = new Command()
2730
.name('resend')
2831
.description('Resend CLI — email for developers')
@@ -50,6 +53,16 @@ const program = new Command()
5053
'Save API key as plaintext instead of secure storage',
5154
)
5255
.hook('preAction', (thisCommand, actionCommand) => {
56+
const parts: string[] = [];
57+
for (
58+
let cmd = actionCommand;
59+
cmd?.parent;
60+
cmd = cmd.parent as typeof actionCommand
61+
) {
62+
parts.unshift(cmd.name());
63+
}
64+
lastCommandName = parts.join(' ');
65+
5366
if (actionCommand.optsWithGlobals().quiet) {
5467
thisCommand.setOptionValue('json', true);
5568
}
@@ -116,6 +129,9 @@ program
116129
if (ran === 'update') {
117130
return;
118131
}
132+
133+
trackCommand(lastCommandName, program.opts());
134+
119135
return checkForUpdates().catch(() => {});
120136
})
121137
.catch((err) => {

src/lib/telemetry.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { getConfigDir } from './config';
4+
import { detectInstallMethodName } from './update-check';
5+
import { VERSION } from './version';
6+
7+
const POSTHOG_API_KEY = 'phc_REPLACE_ME';
8+
const POSTHOG_HOST = 'https://us.i.posthog.com/capture/';
9+
10+
export function isDisabled(): boolean {
11+
return (
12+
process.env.DO_NOT_TRACK === '1' ||
13+
process.env.RESEND_TELEMETRY_DISABLED === '1'
14+
);
15+
}
16+
17+
export function getOrCreateAnonymousId(): string {
18+
const configDir = getConfigDir();
19+
const idPath = join(configDir, 'telemetry-id');
20+
21+
try {
22+
const existing = readFileSync(idPath, 'utf-8').trim();
23+
if (existing) {
24+
return existing;
25+
}
26+
} catch {}
27+
28+
const id = crypto.randomUUID();
29+
mkdirSync(configDir, { recursive: true, mode: 0o700 });
30+
writeFileSync(idPath, id, { mode: 0o600 });
31+
return id;
32+
}
33+
34+
function showFirstRunNotice(): void {
35+
const configDir = getConfigDir();
36+
const markerPath = join(configDir, 'telemetry-notice-shown');
37+
38+
if (existsSync(markerPath)) {
39+
return;
40+
}
41+
42+
mkdirSync(configDir, { recursive: true, mode: 0o700 });
43+
writeFileSync(markerPath, '', { mode: 0o600 });
44+
45+
process.stderr.write(
46+
'\nResend collects anonymous CLI usage data to improve the tool.\n' +
47+
'To opt out: export RESEND_TELEMETRY_DISABLED=1\n\n',
48+
);
49+
}
50+
51+
export function trackCommand(command: string, opts: { json?: boolean }): void {
52+
if (isDisabled()) {
53+
return;
54+
}
55+
56+
try {
57+
showFirstRunNotice();
58+
59+
const distinctId = getOrCreateAnonymousId();
60+
61+
const payload = {
62+
api_key: POSTHOG_API_KEY,
63+
distinct_id: distinctId,
64+
event: 'cli_command',
65+
properties: {
66+
command,
67+
cli_version: VERSION,
68+
os: process.platform,
69+
arch: process.arch,
70+
node_version: process.version,
71+
is_ci:
72+
process.env.CI === 'true' ||
73+
process.env.CI === '1' ||
74+
!!process.env.GITHUB_ACTIONS,
75+
json_mode: !!opts.json,
76+
install_method: detectInstallMethodName(),
77+
},
78+
};
79+
80+
fetch(POSTHOG_HOST, {
81+
method: 'POST',
82+
headers: { 'Content-Type': 'application/json' },
83+
body: JSON.stringify(payload),
84+
signal: AbortSignal.timeout(3000),
85+
}).catch(() => {});
86+
} catch {}
87+
}

src/lib/update-check.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ function shouldSkipCheck(): boolean {
8989
return false;
9090
}
9191

92+
export function detectInstallMethodName(): string {
93+
const full = detectInstallMethod();
94+
if (full.startsWith('npm')) {
95+
return 'npm';
96+
}
97+
if (full.startsWith('brew')) {
98+
return 'homebrew';
99+
}
100+
return 'install-script';
101+
}
102+
92103
export function detectInstallMethod(): string {
93104
const execPath = process.execPath || process.argv[0] || '';
94105
const scriptPath = process.argv[1] || '';

tests/lib/telemetry.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import {
5+
afterEach,
6+
beforeEach,
7+
describe,
8+
expect,
9+
type MockInstance,
10+
test,
11+
vi,
12+
} from 'vitest';
13+
import {
14+
getOrCreateAnonymousId,
15+
isDisabled,
16+
trackCommand,
17+
} from '../../src/lib/telemetry';
18+
import { captureTestEnv } from '../helpers';
19+
20+
const testConfigDir = join(tmpdir(), `resend-telemetry-test-${process.pid}`);
21+
const testResendDir = join(testConfigDir, 'resend');
22+
23+
describe('isDisabled', () => {
24+
const restoreEnv = captureTestEnv();
25+
26+
afterEach(() => {
27+
restoreEnv();
28+
});
29+
30+
test('returns true when DO_NOT_TRACK=1', () => {
31+
process.env.DO_NOT_TRACK = '1';
32+
delete process.env.RESEND_TELEMETRY_DISABLED;
33+
expect(isDisabled()).toBe(true);
34+
});
35+
36+
test('returns true when RESEND_TELEMETRY_DISABLED=1', () => {
37+
delete process.env.DO_NOT_TRACK;
38+
process.env.RESEND_TELEMETRY_DISABLED = '1';
39+
expect(isDisabled()).toBe(true);
40+
});
41+
42+
test('returns false when neither env var set', () => {
43+
delete process.env.DO_NOT_TRACK;
44+
delete process.env.RESEND_TELEMETRY_DISABLED;
45+
expect(isDisabled()).toBe(false);
46+
});
47+
});
48+
49+
describe('getOrCreateAnonymousId', () => {
50+
const restoreEnv = captureTestEnv();
51+
52+
beforeEach(() => {
53+
mkdirSync(testResendDir, { recursive: true });
54+
process.env.XDG_CONFIG_HOME = testConfigDir;
55+
});
56+
57+
afterEach(() => {
58+
restoreEnv();
59+
if (existsSync(testConfigDir)) {
60+
rmSync(testConfigDir, { recursive: true, force: true });
61+
}
62+
});
63+
64+
test('creates and persists a UUID', () => {
65+
const id = getOrCreateAnonymousId();
66+
expect(id).toMatch(
67+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
68+
);
69+
70+
const stored = readFileSync(join(testResendDir, 'telemetry-id'), 'utf-8');
71+
expect(stored).toBe(id);
72+
});
73+
74+
test('returns same ID on subsequent calls', () => {
75+
const first = getOrCreateAnonymousId();
76+
const second = getOrCreateAnonymousId();
77+
expect(first).toBe(second);
78+
});
79+
});
80+
81+
describe('trackCommand', () => {
82+
const restoreEnv = captureTestEnv();
83+
let fetchSpy: MockInstance;
84+
let stderrSpy: MockInstance;
85+
86+
beforeEach(() => {
87+
mkdirSync(testResendDir, { recursive: true });
88+
process.env.XDG_CONFIG_HOME = testConfigDir;
89+
delete process.env.DO_NOT_TRACK;
90+
delete process.env.RESEND_TELEMETRY_DISABLED;
91+
92+
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
93+
ok: true,
94+
} as Response);
95+
96+
stderrSpy = vi
97+
.spyOn(process.stderr, 'write')
98+
.mockImplementation(() => true);
99+
});
100+
101+
afterEach(() => {
102+
fetchSpy.mockRestore();
103+
stderrSpy.mockRestore();
104+
restoreEnv();
105+
if (existsSync(testConfigDir)) {
106+
rmSync(testConfigDir, { recursive: true, force: true });
107+
}
108+
});
109+
110+
test('sends correct payload to PostHog endpoint', () => {
111+
trackCommand('emails send', { json: true });
112+
113+
expect(fetchSpy).toHaveBeenCalledOnce();
114+
const [url, options] = fetchSpy.mock.calls[0] as [string, RequestInit];
115+
expect(url).toBe('https://us.i.posthog.com/capture/');
116+
expect(options.method).toBe('POST');
117+
118+
const body = JSON.parse(options.body as string);
119+
expect(body.event).toBe('cli_command');
120+
expect(body.properties.command).toBe('emails send');
121+
expect(body.properties.json_mode).toBe(true);
122+
expect(body.properties.os).toBe(process.platform);
123+
expect(body.properties.arch).toBe(process.arch);
124+
expect(body.properties.node_version).toBe(process.version);
125+
expect(body.api_key).toBeTruthy();
126+
expect(body.distinct_id).toMatch(
127+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
128+
);
129+
});
130+
131+
test('does nothing when disabled', () => {
132+
process.env.RESEND_TELEMETRY_DISABLED = '1';
133+
trackCommand('emails send', {});
134+
expect(fetchSpy).not.toHaveBeenCalled();
135+
});
136+
137+
test('handles fetch failures gracefully', () => {
138+
fetchSpy.mockRejectedValue(new Error('network error'));
139+
expect(() => trackCommand('emails send', {})).not.toThrow();
140+
});
141+
142+
test('shows first-run notice only once', () => {
143+
trackCommand('emails send', {});
144+
const notice1 = stderrSpy.mock.calls.length;
145+
146+
trackCommand('domains list', {});
147+
const notice2 = stderrSpy.mock.calls.length;
148+
149+
expect(notice1).toBe(1);
150+
expect(notice2).toBe(1);
151+
152+
expect(existsSync(join(testResendDir, 'telemetry-notice-shown'))).toBe(
153+
true,
154+
);
155+
});
156+
});

0 commit comments

Comments
 (0)