Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5de55ff
feat: enrich telemetry event types with env fingerprint, start timest…
nicknisi Apr 14, 2026
4dd5c02
feat: add command-level telemetry, crash reporting, and store-forward…
nicknisi Apr 14, 2026
d282ec9
feat: add WORKOS_DEBUG=1 env var for verbose logging on all commands
nicknisi Apr 14, 2026
5c34159
fix: address review findings for telemetry implementation
nicknisi Apr 14, 2026
99cdfcf
fix: drop telemetry events on 4xx responses to prevent accumulation
nicknisi Apr 14, 2026
d429a93
fix: flush returns boolean, splice prevents race, in-process delivery
nicknisi Apr 14, 2026
8e7a8c0
docs: document telemetry wiring requirements for new commands
nicknisi Apr 15, 2026
d30c97f
feat: add user identification and unclaimed env support to telemetry
nicknisi Apr 15, 2026
c404d5d
fix: sanitize error.message and error.stack in telemetry events
nicknisi Apr 15, 2026
bb8b4b4
chore: de-slop
nicknisi Apr 15, 2026
b04617a
feat: add device.id and auth.mode attributes to telemetry events
nicknisi Apr 15, 2026
77a5308
feat(telemetry): structured termination.reason and error.code on comm…
nicknisi Apr 15, 2026
c6bebf5
feat(telemetry): api.status, api.code, api.resource on API-failure co…
nicknisi Apr 15, 2026
82f23c0
fix(telemetry): address review findings across telemetry signal commits
nicknisi May 12, 2026
d005909
fix(telemetry): close coverage gaps and add guardrail test
nicknisi May 12, 2026
8003788
chore: formatting
nicknisi May 27, 2026
c7804a2
fix(telemetry): address review nits — private commandExecuted, saniti…
nicknisi May 27, 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 number Diff line number Diff 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 auto-emit a `command` telemetry event with name, duration, and success/failure. How you register the command determines whether this is automatic:

**Subcommands via `registerSubcommand()`** → auto-wired. Telemetry happens for free.

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

**Top-level `.command()` with inline handler** → MUST manually wrap with `wrapCommandHandler()`:

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

If you forget `wrapCommandHandler`, the command still emits a telemetry event (queued by middleware), but duration will be `0` and success will always be `true` -- misleading data in dashboards.

**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). Add to this set if you're building another installer entry point.

**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 across `org.list` and `organization.list`.

## Do / Don't

**Do:**
Expand Down
69 changes: 47 additions & 22 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ import {
} from './utils/output.js';
import clack from './utils/clack.js';
import { registerSubcommand } from './utils/register-subcommand.js';
import { installCrashReporter } from './utils/crash-reporter.js';
import { installStoreForward, recoverPendingEvents } from './utils/telemetry-store-forward.js';
import { commandTelemetryMiddleware, wrapCommandHandler } from './utils/command-telemetry.js';
import { analytics } from './utils/analytics.js';

// Enable debug logging for all commands via env var.
// Subsumes the installer's --debug flag for non-installer commands.
if (process.env.WORKOS_DEBUG === '1') {
const { enableDebugLogs } = await import('./utils/debug.js');
enableDebugLogs();
}

// Telemetry infrastructure: crash reporter, store-forward, and gateway init.
// Must be before yargs so crashes during startup are captured.
installCrashReporter();
installStoreForward();
analytics.initForNonInstaller();
// Fire-and-forget: recover events from previous crashes/exits.
// NO await — must not block startup (flush timeout is 3s).
recoverPendingEvents();
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Resolve output mode early from raw argv (before yargs parses)
const rawArgs = hideBin(process.argv);
Expand Down Expand Up @@ -211,6 +231,7 @@ yargs(rawArgs)
describe: 'Interaction mode: human, coding agent, or CI automation',
global: true,
})
.middleware(commandTelemetryMiddleware(rawArgs))
.middleware(async (argv) => {
// Warn about unclaimed environments before management commands.
// Excluded: auth/claim/install/dashboard handle their own credential flows;
Expand Down Expand Up @@ -388,10 +409,10 @@ yargs(rawArgs)
description: 'Auto-update stale WorkOS skills (writes to <agent>/skills/workos/ and workos-widgets/ only)',
},
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
const { handleDoctor } = await import('./commands/doctor.js');
await handleDoctor(argv);
},
}),
)
// NOTE: When adding commands here, also update src/utils/help-json.ts
.command('env', 'Manage environment configurations (API keys, endpoints, active environment)', (yargs) => {
Expand Down Expand Up @@ -523,7 +544,7 @@ yargs(rawArgs)
.example('workos api /user_management/users', 'GET /user_management/users')
.example('workos api /organizations -d \'{"name":"Acme"}\'', 'POST with a JSON body')
.example('workos api /organizations/org_123 -X DELETE', 'DELETE an organization'),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage as boolean | undefined);
const endpoint = argv.endpoint as string | undefined;
const filter = argv.filter as string | undefined;
Expand All @@ -549,7 +570,7 @@ yargs(rawArgs)
dryRun: argv.dryRun,
yes: argv.yes,
});
},
}),
)
.command(['organization', 'org'], 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => {
yargs.options({
Expand Down Expand Up @@ -2134,6 +2155,10 @@ yargs(rawArgs)
return yargs.demandCommand(1, 'Please specify an org-domain subcommand').strict();
})
// --- Workflow Commands ---
// NOTE: Top-level `.command()` registrations with inline handlers MUST wrap
// the handler with `wrapCommandHandler()` for correct command telemetry.
// Subcommands registered via `registerSubcommand()` are auto-wrapped.
// See CLAUDE.md "Telemetry Wiring for New Commands".
.command(
'seed',
'Seed WorkOS environment from a YAML config file',
Expand All @@ -2145,7 +2170,7 @@ yargs(rawArgs)
clean: { type: 'boolean', default: false, describe: 'Tear down seeded resources' },
init: { type: 'boolean', default: false, describe: 'Create an example workos-seed.yml file' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { runSeed } = await import('./commands/seed.js');
Expand All @@ -2154,7 +2179,7 @@ yargs(rawArgs)
resolveApiKey({ apiKey: argv.apiKey }),
resolveApiBaseUrl(),
);
},
}),
)
.command(
'setup-org <name>',
Expand All @@ -2166,7 +2191,7 @@ yargs(rawArgs)
domain: { type: 'string', describe: 'Domain to add and verify' },
roles: { type: 'string', describe: 'Comma-separated role slugs to create' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { runSetupOrg } = await import('./commands/setup-org.js');
Expand All @@ -2175,7 +2200,7 @@ yargs(rawArgs)
resolveApiKey({ apiKey: argv.apiKey }),
resolveApiBaseUrl(),
);
},
}),
)
.command(
'onboard-user <email>',
Expand All @@ -2188,7 +2213,7 @@ yargs(rawArgs)
role: { type: 'string', describe: 'Role slug to assign' },
wait: { type: 'boolean', default: false, describe: 'Wait for invitation acceptance' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { runOnboardUser } = await import('./commands/onboard-user.js');
Expand All @@ -2197,7 +2222,7 @@ yargs(rawArgs)
resolveApiKey({ apiKey: argv.apiKey }),
resolveApiBaseUrl(),
);
},
}),
)
.command(
'debug-sso <connectionId>',
Expand All @@ -2207,12 +2232,12 @@ yargs(rawArgs)
...insecureStorageOption,
'api-key': { type: 'string' as const, describe: 'WorkOS API key' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { runDebugSso } = await import('./commands/debug-sso.js');
await runDebugSso(argv.connectionId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl());
},
}),
)
.command(
'debug-sync <directoryId>',
Expand All @@ -2222,12 +2247,12 @@ yargs(rawArgs)
...insecureStorageOption,
'api-key': { type: 'string' as const, describe: 'WorkOS API key' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
const { runDebugSync } = await import('./commands/debug-sync.js');
await runDebugSync(argv.directoryId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl());
},
}),
)
// Alias — canonical command is `workos env claim`
.command(
Expand All @@ -2237,11 +2262,11 @@ yargs(rawArgs)
yargs.options({
...insecureStorageOption,
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { runClaim } = await import('./commands/claim.js');
await runClaim();
},
}),
)
.command(
'install',
Expand All @@ -2262,10 +2287,10 @@ yargs(rawArgs)
port: { type: 'number', default: 4100, describe: 'Port to listen on' },
seed: { type: 'string', describe: 'Path to seed config file (YAML or JSON)' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
const { runEmulate } = await import('./commands/emulate.js');
await runEmulate({ port: argv.port, seed: argv.seed, json: argv.json as boolean });
},
}),
)
.command(
'dev',
Expand All @@ -2275,14 +2300,14 @@ yargs(rawArgs)
port: { type: 'number', default: 4100, describe: 'Emulator port' },
seed: { type: 'string', describe: 'Path to seed config file' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
const { runDev } = await import('./commands/dev.js');
await runDev({
port: argv.port,
seed: argv.seed,
'--': argv['--'] as string[] | undefined,
});
},
}),
)
.command('debug', false, (yargs) => {
yargs.options(insecureStorageOption);
Expand Down Expand Up @@ -2406,13 +2431,13 @@ yargs(rawArgs)
...insecureStorageOption,
'api-key': { type: 'string' as const, describe: 'WorkOS API key' },
}),
async (argv) => {
wrapCommandHandler(async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { resolveOptionalApiKey } = await import('./lib/api-key.js');
const { getMigrationsPassthroughArgs, runMigrations } = await import('./commands/migrations.js');
const passthrough = getMigrationsPassthroughArgs(rawArgs);
await runMigrations(passthrough, resolveOptionalApiKey({ apiKey: argv.apiKey }));
},
}),
)
.command(
'dashboard',
Expand Down
2 changes: 1 addition & 1 deletion src/commands/api/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ describe('runApiRequest', () => {

it('aborts when the user declines the confirmation prompt', async () => {
mockConfirm.mockResolvedValueOnce(false);
await expect(runApiRequest('/organizations', { method: 'POST', data: '{}' })).rejects.toThrow(/__exit__:0/);
await expect(runApiRequest('/organizations', { method: 'POST', data: '{}' })).rejects.toThrow(/__exit__:2/);
expect(mockApiRequest).not.toHaveBeenCalled();
});

Expand Down
9 changes: 7 additions & 2 deletions src/commands/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { loadCatalog, endpointsByTag } from './catalog.js';
import { apiRequest } from './request.js';
import { resolveApiBaseUrl } from '../../lib/api-key.js';
import { exitWithError, isJsonMode, outputJson } from '../../utils/output.js';
import { ExitCode, exitWithCode } from '../../utils/exit-codes.js';
import { isCiMode, isPromptAllowed } from '../../utils/interaction-mode.js';
import { confirmationRecovery } from '../../utils/recovery-hints.js';
import { formatWorkOSCommandArgs } from '../../utils/command-invocation.js';
Expand Down Expand Up @@ -145,7 +146,7 @@ export async function runApiRequest(endpoint: string, options: ApiCommandOptions
if (hasBody) prettyPrint(body);
const ok = await clack.confirm({ message: 'Proceed?' });
if (!ok || clack.isCancel(ok)) {
process.exit(0);
exitWithCode(ExitCode.CANCELLED);
}
}

Expand All @@ -160,7 +161,11 @@ export async function runApiRequest(endpoint: string, options: ApiCommandOptions
printResponse(response, { includeStatus: options.include });

if (response.status >= 400) {
process.exit(1);
exitWithError({
code: `http_${response.status}`,
message: `API request failed with status ${response.status}`,
apiContext: { status: response.status },
});
}
}

Expand Down
12 changes: 6 additions & 6 deletions src/commands/api/interactive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,20 +232,20 @@ describe('apiInteractive', () => {
expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ body: undefined }));
});

it('exits with code 0 when the user cancels at the category prompt', async () => {
it('exits with code 2 when the user cancels at the category prompt', async () => {
mockSelect.mockResolvedValueOnce(cancelSymbol);

await expect(apiInteractive()).rejects.toThrow(/__exit__:0/);
expect(exitSpy).toHaveBeenCalledWith(0);
await expect(apiInteractive()).rejects.toThrow(/__exit__:2/);
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockApiRequest).not.toHaveBeenCalled();
});

it('exits with code 0 when the user declines the final confirmation', async () => {
it('exits with code 2 when the user declines the final confirmation', async () => {
mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(mockCatalog.endpoints[0]);
mockConfirm.mockResolvedValueOnce(false);

await expect(apiInteractive()).rejects.toThrow(/__exit__:0/);
expect(exitSpy).toHaveBeenCalledWith(0);
await expect(apiInteractive()).rejects.toThrow(/__exit__:2/);
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockApiRequest).not.toHaveBeenCalled();
});

Expand Down
12 changes: 9 additions & 3 deletions src/commands/api/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { loadCatalog, endpointsByTag, type EndpointInfo } from './catalog.js';
import { apiRequest } from './request.js';
import { colorMethod, printResponse } from './format.js';
import { resolveApiKey, resolveApiBaseUrl } from '../../lib/api-key.js';
import { ExitCode, exitWithCode } from '../../utils/exit-codes.js';
import { exitWithError } from '../../utils/output.js';

function assertNotCancelled<T>(value: T | symbol): T {
if (clack.isCancel(value)) process.exit(0);
if (clack.isCancel(value)) exitWithCode(ExitCode.CANCELLED);
return value as T;
}

Expand Down Expand Up @@ -136,7 +138,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi
console.log();

const ok = assertNotCancelled(await clack.confirm({ message: 'Execute this request?' }));
if (!ok) process.exit(0);
if (!ok) exitWithCode(ExitCode.CANCELLED);

const response = await apiRequest({
method: ep.method,
Expand All @@ -149,6 +151,10 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi
printResponse(response, { includeStatus: true });

if (response.status >= 400) {
process.exit(1);
exitWithError({
code: `http_${response.status}`,
message: `API request failed with status ${response.status}`,
apiContext: { status: response.status },
});
}
}
1 change: 1 addition & 0 deletions src/commands/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ interface EnvVarInfo {
}

const ENV_VAR_CATALOG: { name: string; effect: string }[] = [
{ name: 'WORKOS_DEBUG', effect: 'Set to "1" to enable verbose debug logging for all commands' },
{ name: 'WORKOS_API_KEY', effect: 'Bypasses credential resolution — used directly for API calls' },
{ name: 'WORKOS_MODE', effect: 'Controls interaction behavior: human, agent, or CI' },
{ name: 'WORKOS_FORCE_TTY', effect: 'Forces human (non-JSON) output mode, even when piped' },
Expand Down
Loading
Loading