DEV Community

Wilson Xu
Wilson Xu

Posted on

Add Anonymous Usage Analytics to Your CLI Tool

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:

  1. Opt-in or opt-out with clear notice — tell users on first run
  2. Truly anonymous — no IPs, no user IDs, no file paths
  3. Minimal data — only what helps you improve the tool
  4. Transparent — document exactly what you collect
  5. 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  });
Enter fullscreen mode Exit fullscreen mode

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 });
  },
});
Enter fullscreen mode Exit fullscreen mode

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')}`);
    }
  });
Enter fullscreen mode Exit fullscreen mode

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)