Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
11d5381
feat: enrich telemetry event types with env fingerprint, start timest…
nicknisi Apr 14, 2026
fde3634
feat: add command-level telemetry, crash reporting, and store-forward…
nicknisi Apr 14, 2026
1e02159
feat: add WORKOS_DEBUG=1 env var for verbose logging on all commands
nicknisi Apr 14, 2026
c8ac737
fix: address review findings for telemetry implementation
nicknisi Apr 14, 2026
95cc056
fix: drop telemetry events on 4xx responses to prevent accumulation
nicknisi Apr 14, 2026
2c3d469
fix: flush returns boolean, splice prevents race, in-process delivery
nicknisi Apr 14, 2026
aea1074
docs: document telemetry wiring requirements for new commands
nicknisi Apr 15, 2026
97d9b85
feat: add user identification and unclaimed env support to telemetry
nicknisi Apr 15, 2026
ec0675b
fix: sanitize error.message and error.stack in telemetry events
nicknisi Apr 15, 2026
5ffaed1
chore: de-slop
nicknisi Apr 15, 2026
5558f37
feat: add device.id and auth.mode attributes to telemetry events
nicknisi Apr 15, 2026
0d9f061
feat(telemetry): structured termination.reason and error.code on comm…
nicknisi Apr 15, 2026
d046d9a
feat(telemetry): api.status, api.code, api.resource on API-failure co…
nicknisi Apr 15, 2026
e4fc99a
fix(telemetry): address review findings across telemetry signal commits
nicknisi May 12, 2026
6f9bf6e
fix(telemetry): close coverage gaps and add guardrail test
nicknisi May 12, 2026
48c20b5
chore: formatting
nicknisi May 27, 2026
f3e328e
fix(telemetry): address review nits — private commandExecuted, saniti…
nicknisi May 27, 2026
a2f3536
feat(telemetry): add CliExit error class for structured command exits
nicknisi May 27, 2026
489ecaf
refactor(telemetry): exitWithCode throws CliExit instead of process.exit
nicknisi May 27, 2026
c8e32a0
refactor(telemetry): exitWithError throws CliExit instead of process.…
nicknisi May 27, 2026
832b62f
refactor(telemetry): replace provisional/patch methods with single em…
nicknisi May 28, 2026
bac5b19
refactor(telemetry): remove replaceLastEventOfType and patchLastEvent…
nicknisi May 28, 2026
2a953ae
refactor(telemetry): remove commandTelemetryMiddleware, wrapCommandHa…
nicknisi May 28, 2026
5b34407
refactor(telemetry): replace process.exit in install.ts with CliExit/…
nicknisi May 28, 2026
8b9fd22
feat(telemetry): centralize command lifecycle in runCli with parseAsync
nicknisi May 28, 2026
de13b0f
docs: update telemetry wiring docs for centralized lifecycle
nicknisi May 28, 2026
cb2b966
fix(telemetry): update tests for CliExit-based exits
nicknisi May 28, 2026
face43f
fix(telemetry): address three integration bugs in centralized lifecycle
nicknisi May 28, 2026
3889017
fix(telemetry): catch CliExit from pre-lifecycle --mode validation
nicknisi May 28, 2026
ac11d54
chore: formatting
nicknisi May 28, 2026
3d96af1
fix(telemetry): use direct process.exit for pre-lifecycle --mode vali…
nicknisi May 28, 2026
31706cc
docs: update README telemetry section for command-level coverage
nicknisi May 28, 2026
b1eb359
fix(telemetry): address review feedback and align crash payload with …
nicknisi May 28, 2026
f93544f
feat(telemetry): route telemetry to /cli with API-key transport auth
nicknisi May 28, 2026
cfed292
feat(telemetry): tag installer sessions with detected integration
nicknisi May 28, 2026
5cc1342
Refine telemetry lifecycle and auth handling
nicknisi Jun 1, 2026
79fdd40
fix(telemetry): do not authenticate telemetry with an expired JWT
nicknisi Jun 1, 2026
360fe58
test(telemetry): isolate keyring in tests to prevent real-keychain wipes
nicknisi Jun 1, 2026
199b9d9
fix(telemetry): attribute validation_error and crash events to the re…
nicknisi Jun 1, 2026
1b50abb
fix(telemetry): never record an option value or secret as command.name
nicknisi Jun 1, 2026
fe2645a
fix(cli): surface unexpected errors instead of exiting silently
nicknisi Jun 1, 2026
13f2b9a
test(telemetry): fully isolate the spawned CLI from the host keychain
nicknisi Jun 1, 2026
9aae582
fix(telemetry): coalesce concurrent flush() calls
nicknisi Jun 1, 2026
726b17c
refactor(telemetry): load device id off the blocking-fs path
nicknisi Jun 1, 2026
4f0958a
refactor(install): exit via exitWithCode instead of throwing CliExit …
nicknisi Jun 1, 2026
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
33 changes: 33 additions & 0 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,39 @@ pnpm typecheck # Type check
2. Register in `src/bin.ts` and update `src/utils/help-json.ts` command registry
3. Include JSON mode tests in spec file

## Telemetry Wiring for New Commands

All commands automatically emit a `command` telemetry event with name, duration, and success/failure. The centralized lifecycle in `bin.ts` (`runCli()`) handles this — no manual wrapping required.

**Subcommands via `registerSubcommand()`** — auto-tracked. Just write the handler:

```typescript
.command('user', 'Manage users', (yargs) => {
registerSubcommand(yargs, 'reset-password', '...', (y) => y,
async (argv) => { await runResetPassword(argv); },
);
})
```

**Top-level `.command()` with inline handler** — also auto-tracked:

```typescript
.command(
'migrate',
'Migrate from another provider',
(yargs) => yargs.options({...}),
async (argv) => {
await runMigrate(argv);
},
)
```

**Exiting with errors:** Use `exitWithError()` or `exitWithCode()` from handlers — they throw `CliExit` which the lifecycle catches, classifies, and records.

**Skip list**: Commands in `SKIP_TELEMETRY_COMMANDS` (`command-telemetry.ts`) are excluded from command-level telemetry because they have their own session-based telemetry. Currently: `install`, `dashboard`, `root` (the default `$0` handler).

**Aliases**: if you register a command with multiple names (e.g., `['organization', 'org']`), add the alias to `src/lib/command-aliases.ts` so metrics don't fragment.

## Do / Don't

**Do:**
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -736,17 +736,18 @@ OAuth credentials are stored in the system keychain (with `~/.workos/credentials

## Telemetry

The installer collects anonymous usage telemetry to help improve the product:
The CLI collects anonymous usage telemetry to help improve the product:

- Session outcome (success/error/cancelled)
- Framework detected
- Duration and step timing
- Token usage (for capacity planning)
- **Command events** -- command name, duration, success/failure, termination reason, and which flags were used (for telemetry-enabled commands; `install` and `dashboard` use session events instead)
- **Session events** -- framework detected, step timing, token usage (installer only)
- **Crash events** -- sanitized error type and stack trace (no secrets, truncated to 4KB)

No code, credentials, or personal data is collected. Disable with:
Environment fingerprint (OS, Node version, shell, CI detection) is included on all events. No code, credentials, or personal data is collected.

Disable with:

```bash
WORKOS_TELEMETRY=false npx workos@latest install
WORKOS_TELEMETRY=false workos <command>
```

## Logs
Expand Down
122 changes: 122 additions & 0 deletions src/bin-command-telemetry.integration.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

/**
* Integration test for the command-telemetry lifecycle in bin.ts.
*
* bin.ts runs runCli() at import and exposes no seams, so the only honest way
* to test the wiring is to drive the real CLI as a subprocess. We run a KNOWN
* command with a missing required argument and assert that the resulting
* `command` telemetry event is recorded (attributed to the real command, with
* a validation_error reason) rather than being silently dropped.
*
* Regression guard for the gap where yargs runs its demand/strict validation
* before dispatching middleware: a validation failure short-circuited before
* the command-name middleware ran, leaving the name as 'root' (which is in
* SKIP_TELEMETRY_COMMANDS), so we lost telemetry for every misused command.
*
* We observe the emitted event via the store-forward pending file rather than
* stdout: pointing the CLI at an unroutable telemetry URL makes the flush fail,
* so the queued events are persisted to <TMPDIR>/workos-cli-telemetry/ on exit.
* That captures the real event payload, independent of debug-log formatting.
*/
const binPath = fileURLToPath(new URL('./bin.ts', import.meta.url));
const forceInsecureStorageImport = new URL('./test/force-insecure-storage.ts', import.meta.url).href;
const repoRoot = fileURLToPath(new URL('..', import.meta.url));

let sandboxTmp: string;

beforeEach(() => {
sandboxTmp = mkdtempSync(join(tmpdir(), 'wos-cli-telem-it-'));
});

afterEach(() => {
rmSync(sandboxTmp, { recursive: true, force: true });
});

function runCli(args: string[]) {
const env: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
HOME: sandboxTmp,
USERPROFILE: sandboxTmp,
TMPDIR: sandboxTmp,
TMP: sandboxTmp,
TEMP: sandboxTmp,
// Keep prompts/update checks disabled without inheriting host agent/CI env.
WORKOS_MODE: 'agent',
// Force telemetry on so a host WORKOS_TELEMETRY=false can't make the test
// silently produce no event and fail.
WORKOS_TELEMETRY: 'true',
// Unroutable URL: the flush fails, so the queued events are persisted to
// the pending file on exit where we can inspect the real payload.
WORKOS_TELEMETRY_URL: 'http://127.0.0.1:59999/cli',
WORKOS_API_KEY: 'sk_dummy_for_test',
};

const result = spawnSync(
process.execPath,
['--import', 'tsx', '--import', forceInsecureStorageImport, binPath, ...args],
{
cwd: repoRoot,
encoding: 'utf-8',
env,
},
);

const events: Array<{ type: string; attributes?: Record<string, unknown> }> = [];
const pendingDir = join(sandboxTmp, 'workos-cli-telemetry');
for (const file of readdirSync(pendingDir, { withFileTypes: true })) {
if (file.isFile() && file.name.startsWith('pending-') && file.name.endsWith('.json')) {
events.push(...JSON.parse(readFileSync(join(pendingDir, file.name), 'utf-8')));
}
}
return { result, events };
}

describe('command telemetry lifecycle', () => {
it('emits a command event for a known command that fails validation', () => {
// `organization create` requires a positional `name`; omitting it is a
// validation error on a real, known command.
const { result, events } = runCli(['organization', 'create']);

expect(`${result.stdout}\n${result.stderr}`).toContain('Not enough non-option arguments');

const command = events.find((e) => e.type === 'command');
expect(command).toBeDefined();
// Validation fails before middleware runs, so only the top-level command is
// recovered (subcommand precision is intentionally dropped to avoid leaking
// positional values). The key regression: it's attributed, not skipped as 'root'.
expect(command?.attributes?.['command.name']).toBe('organization');
expect(command?.attributes?.['termination.reason']).toBe('validation_error');
expect(command?.attributes?.['command.success']).toBe(false);
}, 20_000);

it('records a crash event with a redacted stack when a command crashes unexpectedly', () => {
// `debug simulate --crash` throws a plain Error (not CliExit), which the
// lifecycle must classify as a crash (not validation_error).
const { result, events } = runCli(['debug', 'simulate', '--crash']);
expect(result.status).not.toBe(0);

// A crash must not be a silent exit-1: the error surfaces on stderr.
expect(`${result.stdout}\n${result.stderr}`).toMatch(/Simulated crash/);

const crash = events.find((e) => e.type === 'crash');
expect(crash).toBeDefined();
expect(crash?.attributes?.['crash.error_type']).toBe('Error');
expect(crash?.attributes?.['crash.command']).toBe('debug.simulate');

// The accompanying command event is classified as a crash, not a validation error.
const command = events.find((e) => e.type === 'command');
expect(command?.attributes?.['termination.reason']).toBe('crash');

// Stack must be redacted: no absolute home path, no full repo path.
const stack = String(crash?.attributes?.['crash.stack'] ?? '');
expect(stack).toContain('Simulated crash');
expect(stack).not.toMatch(/\/Users\/[^/]+\//); // POSIX home dir collapsed to ~
expect(stack).not.toContain(repoRoot);
}, 20_000);
});
Loading
Loading