Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Telemetry data is **pseudonymous, not anonymous**. Events include a randomly gen

On your first CLI command, a telemetry notice is displayed and **no telemetry is collected**.

Telemetry starts from your next command, giving you an opportunity to opt out before any event is recorded.
Telemetry starts from your next command, giving you an opportunity to opt out before any event is recorded. That first recorded event carries the date the notice was shown (`first_run`) and is sent straight away, so Chargebee can count new installs; later events are sent in batches.

## Commands

Expand All @@ -25,7 +25,9 @@ chargebee telemetry status --pending # View events waiting to be sent

## What is Collected

The CLI normally records **one event per command**. `chargebee listen` may record multiple lifecycle events while it is running.
The CLI normally records **one event per command**, including `--help` and `--version`. `chargebee listen` may record multiple lifecycle events while it is running.

Events are queued locally and sent in the background, in batches of five or once the oldest queued event is five minutes old, checked when a command finishes. An install's first recorded event is sent immediately.

Telemetry is sent to a Chargebee owned endpoint. The destination cannot be changed through CLI configuration or environment variables.

Expand All @@ -48,6 +50,9 @@ A telemetry request has the following structure:
"os": "darwin",
"arch": "arm64",
"rt": "node",
"rtv": "22.12",
"tty": "true",
"im": "npm",
"ci": "false"
}
}
Expand All @@ -68,15 +73,19 @@ A telemetry request has the following structure:
| `os` | Always | Operating system, such as `darwin`. |
| `arch` | Always | System architecture, such as `arm64`. |
| `rt` | Always | Runtime used by the CLI, such as `node` or `bun`. |
| `rtv` | Always | Runtime version as major.minor, such as `22.12`. |
| `tty` | Always | `true` when both input and output are a terminal; `false` for pipes, scripts and most agents. |
| `im` | When detected | How the CLI was installed: `npm`, `pnpm`, `yarn`, `bun-global`, `github` (installer or release binary) or `source`. |
| `status` | Always | Whether the command completed with `ok` or `error`. |
| `ci` | Always | `false` for recorded events because telemetry is disabled in CI. |
| `flags` | When flags are used | Flag names only, such as `data,fields`. **Flag values are never collected.** |
| `flags` | When flags are used | Flag names only, such as `data,fields`. **Flag values are never collected.** `--help` and `--version` are recorded as `help` and `version`. |
| `dur_ms` | Non-interactive commands | Command duration in milliseconds. Omitted for interactive commands and `listen`. |
| `err_type` | Errors | Coarse error category such as `api_404` or `cli_error`. Error messages are never collected. |
| `pcv` | When available | Product Catalog version (`v1` or `v2`) of the configured site. |
| `code_lang` | Code generation | Generated code language, such as `go` or `js`. |
| `agent` | When detected | Supported agent runtime, such as `cursor` or `claude-code`. |
| `agent` | When detected | Supported agent runtime: `claude-code`, `cursor`, `codex` (sandboxed runs) or `gemini-cli`, detected from environment variables those tools set. |
| `listen_phase` | `chargebee listen` | Tunnel lifecycle state: `established`, `closed`, or `error`. |
| `first_run` | First recorded event only | Date (`YYYY-MM-DD`) the first-run notice was shown. |

## What is Never Collected

Expand All @@ -92,6 +101,8 @@ Chargebee CLI telemetry does **not** collect:

The command itself and flag **names** may be collected, but their values are never included.

Like any HTTPS request, telemetry requests are handled by network infrastructure whose request logs record the connecting IP address and user agent for security and operations. The CLI does not add the IP address or user agent to telemetry events.

## Environment Variables and CI

Telemetry is automatically disabled when:
Expand Down
2 changes: 1 addition & 1 deletion src/commands/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ function showStatus(): void {
}
}
humanLog();
humanLog(" Sent : command name, flag names, outcome, CLI version, OS/arch, the install id above, and your site name.");
humanLog(" Sent : command name, flag names, outcome, CLI version and install method, OS/arch and runtime, the install id above, and your site name.");
humanLog(" Never sent : argument values, API keys, customer data or webhook payloads.");
humanLog(" Learn more : https://github.com/chargebee/cli/blob/main/TELEMETRY.md");
humanLog(disabled ? " Enable with : chargebee telemetry enable" : " Disable with: chargebee telemetry disable");
Expand Down
79 changes: 66 additions & 13 deletions src/lib/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import { wasInteractive } from "./interactive.js";
import { isTelemetryDisabled, maybeShowFirstRunNotice } from "./optout.js";
import { getVisitorId } from "./identity.js";
import { appendRecord, spoolStats } from "./spool.js";
import { readState } from "./state.js";
import { readState, writeState } from "./state.js";
import { detectInstallMethod } from "../update/index.js";
import { knownCommandPath } from "./command-path.js";
import type { SpoolRecord } from "./types.js";

Expand Down Expand Up @@ -89,26 +90,57 @@ interface PendingInvocation {
siteName: string;
pcv?: string;
env: string;
installMethod?: string;
}

let pending: PendingInvocation | null = null;

function ensurePendingForUsage(program: Command): void {
/** Install method for the event; never lets detection break telemetry. */
function safeInstallMethod(): string | undefined {
try {
return detectInstallMethod();
} catch {
return undefined;
}
}

/**
* Seed an event for an invocation that never reaches preAction: a parse failure
* (`usage`), or `--help` / `--version`, which Commander handles itself.
*/
function ensurePendingWithoutAction(program: Command, fallbackName: string, flagNames: string[]): void {
if (pending || isTelemetryDisabled()) return;
// Parse failures skip preAction, so the notice may not have been printed yet.
// These paths skip preAction, so the notice may not have been printed yet.
// Nothing is recorded until the user has seen the notice at least once.
if (!readState().notice_shown) return;
const command = knownCommandPath(program, process.argv.slice(2));
if (command === FLUSH_COMMAND) return;
pending = {
command: command || "unknown",
flagNames: [],
command: command || fallbackName,
flagNames,
visitorId: getVisitorId(),
siteName: "unconfigured",
env: "production",
installMethod: safeInstallMethod(),
};
}

/**
* The notice date to stamp on this install's first recorded event, or undefined.
* Installs that saw the notice before `notice_shown_at` existed have no date and
* are never reported as new.
*/
function pendingFirstRun(): string | undefined {
const state = readState();
if (state.first_event_recorded || !state.notice_shown_at) return undefined;
return state.notice_shown_at;
}

/** Mark the activation event as recorded (also for upgraded installs, so the check stays cheap). */
function markFirstEventRecorded(): void {
if (!readState().first_event_recorded) writeState({ first_event_recorded: true });
}

/** Build the dotted command path (e.g. "addon create"), excluding the root program. */
function commandPath(cmd: Command): string {
const names: string[] = [];
Expand Down Expand Up @@ -183,6 +215,7 @@ async function begin(actionCommand: Command): Promise<void> {
siteName: siteName || "unconfigured",
pcv,
env: telemetryEnvLabel(host),
installMethod: safeInstallMethod(),
};
} catch {
// telemetry must never break a command
Expand All @@ -207,6 +240,8 @@ export function emitListenPhase(phase: ListenPhase, errorType?: string): void {
phase === "error" ? (errorType ?? "listen_connect_error") : undefined,
productCatalogVersion: pending.pcv,
listenPhase: phase,
installMethod: pending.installMethod,
firstRun: pendingFirstRun(),
});

appendRecord({
Expand All @@ -220,6 +255,7 @@ export function emitListenPhase(phase: ListenPhase, errorType?: string): void {
metadata,
},
});
markFirstEventRecorded();
spawnFlush();
} catch {
// telemetry must never break listen
Expand All @@ -238,6 +274,7 @@ function finalize(exitCode: number): void {
const status: "ok" | "error" = exitCode === 0 ? "ok" : "error";
const errorType = status === "error" ? takeTelemetryError() ?? "nonzero_exit" : undefined;
const skipDuration = wasInteractive() || pending.command === "listen";
const firstRun = pendingFirstRun();

const metadata = buildMetadata({
flagNames: pending.flagNames,
Expand All @@ -248,6 +285,8 @@ function finalize(exitCode: number): void {
errorType,
productCatalogVersion: pending.pcv,
generatedResource: pending.generatedResource,
installMethod: pending.installMethod,
firstRun,
});

const record: SpoolRecord = {
Expand All @@ -259,9 +298,12 @@ function finalize(exitCode: number): void {
};

appendRecord(record);
markFirstEventRecorded();

// Send a new install's first event right away: otherwise someone who tries the
// CLI once or twice never reaches BATCH_SIZE and is never seen.
const { count, oldestAgeMs } = spoolStats();
if (count >= BATCH_SIZE || oldestAgeMs >= MAX_AGE_MS) spawnFlush();
if (firstRun || count >= BATCH_SIZE || oldestAgeMs >= MAX_AGE_MS) spawnFlush();
} catch {
// swallow — never block process exit
}
Expand Down Expand Up @@ -309,20 +351,31 @@ export function installTelemetry(program: Command, version: string): void {
await begin(actionCommand as Command);
});

// Commander prints the usage error, then calls this instead of process.exit.
// Parse-time failures (unknown command, missing required arg) skip preAction,
// so seed a pending event from registered command names only.
program.exitOverride((err: CommanderError) => {
// Commander prints the usage error (or help / version), then calls this instead of
// process.exit. These paths skip preAction, so seed a pending event from
// registered command names only. Installed on every command: Commander raises a
// subcommand's parse error or `--help` through that subcommand's own callback,
// and `configureJsonOutput` has already given each one an override.
const onCommanderExit = (err: CommanderError): never => {
try {
if (err.exitCode !== 0) {
recordTelemetryError("usage");
ensurePendingForUsage(program);
ensurePendingWithoutAction(program, "unknown", []);
} else if (err.code === "commander.helpDisplayed" || err.code === "commander.help") {
ensurePendingWithoutAction(program, "help", ["help"]);
} else if (err.code === "commander.version") {
ensurePendingWithoutAction(program, "version", ["version"]);
}
} catch {
// telemetry must never break commander
}
exitCommand(err.exitCode, err.code);
});
return exitCommand(err.exitCode, err.code);
};
const overrideAll = (cmd: Command): void => {
cmd.exitOverride(onCommanderExit);
for (const sub of cmd.commands) overrideAll(sub);
};
overrideAll(program);

if (exitHandler) process.removeListener("exit", exitHandler);
exitHandler = onProcessExit;
Expand Down
31 changes: 29 additions & 2 deletions src/lib/telemetry/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export interface MetadataInput {
productCatalogVersion?: string;
generatedResource?: string;
listenPhase?: "established" | "closed" | "error";
/** How this copy was installed (npm, github, pnpm, ...), from the update module. */
installMethod?: string;
/** Date (YYYY-MM-DD) the first-run notice was shown; set only on an install's first event. */
firstRun?: string;
}

const MAX_VALUE_LEN = 1024;
Expand Down Expand Up @@ -72,15 +76,34 @@ export function detectIsCi(): boolean {
/**
* Best-effort detection of the AI agent driving the CLI, if any.
*
* Not User-Agent regexes — these are env vars the agent runtimes set. Order
* matters (first match wins): Claude Code, then Cursor. Closed set on purpose.
* Not User-Agent regexes — these are env vars the agent runtimes set for the
* commands they spawn. Order matters (first match wins). Closed set on purpose:
* only agents with a documented marker are listed. Codex sets its markers only
* when it sandboxes the command, so unsandboxed Codex runs are not detected.
*/
export function detectAiAgent(): string | undefined {
if (process.env.CLAUDECODE || process.env.CLAUDE_CODE) return "claude-code";
if (process.env.CURSOR_TRACE_ID || process.env.CURSOR_AGENT) return "cursor";
if (process.env.CODEX_SANDBOX || process.env.CODEX_SANDBOX_NETWORK_DISABLED) return "codex";
if (process.env.GEMINI_CLI) return "gemini-cli";
return undefined;
}

/** Runtime version as major.minor (Bun wins over the Node compatibility version it reports). */
export function runtimeVersion(versions: { node?: string; bun?: string } = process.versions): string {
const v = versions.bun ?? versions.node;
const m = v ? /^(\d+)\.(\d+)/.exec(v) : null;
return m ? `${m[1]}.${m[2]}` : "unknown";
}

/** Whether a person is at a terminal: both stdin and stdout are TTYs (false for pipes, scripts, most agents). */
export function isTerminal(
stdin: { isTTY?: boolean } = process.stdin,
stdout: { isTTY?: boolean } = process.stdout,
): boolean {
return stdin.isTTY === true && stdout.isTTY === true;
}

function detectRuntime(): string {
return (process as unknown as { versions?: { bun?: string } }).versions?.bun ? "bun" : "node";
}
Expand All @@ -95,6 +118,8 @@ export function buildMetadata(input: MetadataInput): Record<string, string> {
os: process.platform,
arch: process.arch,
rt: detectRuntime(),
rtv: runtimeVersion(),
tty: String(isTerminal()),
status: input.status,
ci: String(detectIsCi()),
};
Expand All @@ -105,6 +130,8 @@ export function buildMetadata(input: MetadataInput): Record<string, string> {
if (input.productCatalogVersion) meta.pcv = input.productCatalogVersion;
if (input.generatedResource) meta.code_lang = clip(input.generatedResource);
if (input.listenPhase) meta.listen_phase = input.listenPhase;
if (input.installMethod) meta.im = input.installMethod;
if (input.firstRun) meta.first_run = input.firstRun;

const agent = detectAiAgent();
if (agent) meta.agent = agent;
Expand Down
4 changes: 2 additions & 2 deletions src/lib/telemetry/optout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function setTelemetryEnabled(enabled: boolean): boolean {
const NOTICE = [
"",
" Chargebee CLI sends usage data to Chargebee: command name, flag names, exit status, error category,",
" CLI version, OS/arch, a random install id, and the Chargebee site name of the active profile.",
" CLI version and install method, OS/arch and runtime, a random install id, and the Chargebee site name of the active profile.",
Comment thread
cb-muthiahm marked this conversation as resolved.
" Never sent: argument values, API keys, customer data or webhook payloads. This run was not recorded.",
" Opt out anytime: chargebee telemetry disable | https://github.com/chargebee/cli/blob/main/TELEMETRY.md",
"",
Expand All @@ -104,6 +104,6 @@ export function maybeShowFirstRunNotice(): boolean {
} catch {
// ignore
}
writeState({ notice_shown: true });
writeState({ notice_shown: true, notice_shown_at: new Date().toISOString().slice(0, 10) });
return true;
}
14 changes: 14 additions & 0 deletions src/lib/telemetry/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export interface TelemetryState {
enabled: boolean;
/** Whether the one-time first-run notice has been shown. */
notice_shown: boolean;
/**
* UTC date (YYYY-MM-DD) the notice was shown. Empty for installs that saw the
* notice before this field existed, so they are never reported as new.
*/
notice_shown_at: string;
/** Whether this install has recorded its first event (the activation event). */
first_event_recorded: boolean;
/** Consecutive flush runs that ended with at least one failed batch. */
consecutive_flush_failures: number;
/** Epoch ms before which `spawnFlush` skips spawning a new flush child. */
Expand All @@ -29,6 +36,8 @@ const DEFAULT_STATE: TelemetryState = {
anonymous_id: "",
enabled: true,
notice_shown: false,
notice_shown_at: "",
first_event_recorded: false,
consecutive_flush_failures: 0,
next_flush_attempt_at: 0,
};
Expand All @@ -43,6 +52,11 @@ export function readState(): TelemetryState {
typeof parsed.anonymous_id === "string" ? parsed.anonymous_id : "",
enabled: parsed.enabled !== false,
notice_shown: parsed.notice_shown === true,
notice_shown_at:
typeof parsed.notice_shown_at === "string" && /^\d{4}-\d{2}-\d{2}$/.test(parsed.notice_shown_at)
? parsed.notice_shown_at
: "",
first_event_recorded: parsed.first_event_recorded === true,
consecutive_flush_failures:
typeof parsed.consecutive_flush_failures === "number" ? parsed.consecutive_flush_failures : 0,
next_flush_attempt_at:
Expand Down
Loading
Loading