Add Anonymous Usage Analytics to Your CLI Tool
You built a CLI tool. People install it. But you have no idea how they use it — which commands are popular, which flags they use, what errors they hit, or which Node.js versions they run. Without this data, you're making product decisions blind.
Anonymous, opt-in usage analytics solve this. Major CLI tools like Next.js CLI, Gatsby CLI, and Angular CLI all collect anonymous telemetry. This article shows how to do it right — respecting privacy while getting actionable insights.
The Ethics First
Before writing code, establish these principles:
- Opt-in or opt-out with clear notice — tell users on first run
- Truly anonymous — no IPs, no user IDs, no file paths
- Minimal data — only what helps you improve the tool
- Transparent — document exactly what you collect
-
Respect
DO_NOT_TRACK— the environment variable standard
// lib/telemetry.ts
function isEnabled(): boolean {
// Respect DO_NOT_TRACK standard
if (process.env.DO_NOT_TRACK === '1') return false;
// Respect tool-specific opt-out
if (process.env.MYTOOL_TELEMETRY === 'off') return false;
// Check stored preference
const config = loadConfig();
if (config.telemetry === false) return false;
// Not in CI by default
if (process.env.CI === 'true') return false;
return true;
}
First-Run Notice
import chalk from 'chalk';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
const CONFIG_DIR = join(homedir(), '.mytool');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
async function showTelemetryNotice(): Promise<void> {
const config = await loadConfig();
if (config._telemetryNoticeShown) return;
console.error(chalk.gray(`
${chalk.bold('Anonymous Usage Analytics')}
This tool collects anonymous usage data to improve the product.
No personal information is collected. No file paths or content.
To opt out: set MYTOOL_TELEMETRY=off or run: mytool config set telemetry false
Learn more: https://github.com/you/mytool/blob/main/TELEMETRY.md
`));
config._telemetryNoticeShown = true;
await saveConfig(config);
}
What to Collect
Only collect data that helps you make decisions:
interface TelemetryEvent {
// What happened
event: string; // 'command_run', 'error', 'install'
command?: string; // 'audit', 'check', 'init'
// Context (all anonymous)
nodeVersion: string; // '20.11.0'
toolVersion: string; // '1.2.3'
os: string; // 'darwin', 'linux', 'win32'
arch: string; // 'arm64', 'x64'
// Usage patterns
flags?: string[]; // ['--json', '--verbose'] (names only, no values!)
duration?: number; // 1234 (ms)
exitCode?: number; // 0, 1, 2
// Error info (generic, no stack traces)
errorType?: string; // 'NetworkError', 'ValidationError'
// Timestamp (day granularity for privacy)
date: string; // '2026-03-19' (no time)
}
Never collect: file paths, environment variable values, API keys, URLs with tokens, full error messages, stack traces, IP addresses.
Batch and Send
Don't slow down the CLI with network requests. Queue events and send them in the background:
const EVENT_QUEUE_FILE = join(CONFIG_DIR, 'telemetry-queue.json');
export async function trackEvent(event: TelemetryEvent): Promise<void> {
if (!isEnabled()) return;
try {
const queue = await loadQueue();
queue.push(event);
// Batch: only send when we have 10+ events or it's been 24+ hours
if (queue.length >= 10) {
await sendEvents(queue);
await saveQueue([]);
} else {
await saveQueue(queue);
}
} catch {
// Telemetry should never break the tool
}
}
async function sendEvents(events: TelemetryEvent[]): Promise<void> {
// Fire and forget — don't await in the main process
const controller = new AbortController();
setTimeout(() => controller.abort(), 3000); // 3s timeout max
try {
await fetch('https://telemetry.mytool.dev/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events }),
signal: controller.signal,
});
} catch {
// Silent failure — never impact user experience
}
}
Integration Points
Track at command start and end:
program
.command('audit <url>')
.action(async (url, options) => {
const start = performance.now();
await showTelemetryNotice();
try {
const result = await runAudit(url, options);
printReport(result);
await trackEvent({
event: 'command_run',
command: 'audit',
flags: Object.keys(options).filter(k => options[k] !== undefined),
duration: Math.round(performance.now() - start),
exitCode: 0,
nodeVersion: process.version.slice(1),
toolVersion: pkg.version,
os: process.platform,
arch: process.arch,
date: new Date().toISOString().split('T')[0],
});
} catch (error) {
await trackEvent({
event: 'error',
command: 'audit',
errorType: error.constructor.name,
exitCode: error.code || 1,
nodeVersion: process.version.slice(1),
toolVersion: pkg.version,
os: process.platform,
arch: process.arch,
date: new Date().toISOString().split('T')[0],
});
throw error;
}
});
The Telemetry Server
A simple endpoint to receive and store events:
// server/index.ts (separate deployment)
import { serve } from 'bun';
const events: TelemetryEvent[] = [];
serve({
port: 3001,
async fetch(req) {
if (req.method === 'POST' && new URL(req.url).pathname === '/events') {
const body = await req.json();
events.push(...body.events);
// Aggregate and store (details depend on your analytics backend)
await storeEvents(body.events);
return new Response('ok');
}
return new Response('not found', { status: 404 });
},
});
Useful Queries on Telemetry Data
Once you have data, these queries drive decisions:
- Most used commands → prioritize improvements
- Error rate by command → find bugs
- Node.js version distribution → set minimum version
- OS distribution → platform-specific testing priority
- Average command duration → performance regression detection
- Flag usage frequency → which features matter
Opt-Out Commands
Make opting out trivial:
program
.command('telemetry')
.description('Manage telemetry settings')
.option('--enable', 'Enable anonymous analytics')
.option('--disable', 'Disable anonymous analytics')
.option('--status', 'Show current setting')
.action(async (options) => {
if (options.disable) {
await updateConfig({ telemetry: false });
console.log(chalk.green(' ✓ Telemetry disabled'));
} else if (options.enable) {
await updateConfig({ telemetry: true });
console.log(chalk.green(' ✓ Telemetry enabled'));
} else {
const enabled = isEnabled();
console.log(` Telemetry: ${enabled ? chalk.green('enabled') : chalk.red('disabled')}`);
}
});
Conclusion
Anonymous telemetry isn't surveillance — it's product research. When done right (opt-in, minimal data, transparent, respectful of DO_NOT_TRACK), it gives you the insights to build a better tool. The key is collecting only what you need and never compromising user privacy.
Wilson Xu builds privacy-respecting developer tools. Find his 12+ packages at npm.
Top comments (0)