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
42 changes: 38 additions & 4 deletions src/bin-command-telemetry.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -38,7 +38,14 @@ afterEach(() => {
rmSync(sandboxTmp, { recursive: true, force: true });
});

function runCli(args: string[]) {
/** Seed ~/.workos/preferences.json inside the sandboxed HOME before a run. */
function seedPreferences(prefs: unknown): void {
const workosDir = join(sandboxTmp, '.workos');
mkdirSync(workosDir, { recursive: true });
writeFileSync(join(workosDir, 'preferences.json'), JSON.stringify(prefs), 'utf-8');
}

function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}) {
const env: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
HOME: sandboxTmp,
Expand All @@ -49,12 +56,14 @@ function runCli(args: string[]) {
// 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.
// silently produce no event and fail. Tests that exercise env precedence
// override this explicitly via envOverrides.
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',
...envOverrides,
};

const result = spawnSync(
Expand All @@ -69,7 +78,13 @@ function runCli(args: string[]) {

const events: Array<{ type: string; attributes?: Record<string, unknown> }> = [];
const pendingDir = join(sandboxTmp, 'workos-cli-telemetry');
for (const file of readdirSync(pendingDir, { withFileTypes: true })) {
let entries: ReturnType<typeof readdirSync> = [];
try {
entries = readdirSync(pendingDir, { withFileTypes: true });
} catch {
// No pending dir => no events were ever queued (e.g. opted out).
}
for (const file of entries) {
if (file.isFile() && file.name.startsWith('pending-') && file.name.endsWith('.json')) {
events.push(...JSON.parse(readFileSync(join(pendingDir, file.name), 'utf-8')));
}
Expand Down Expand Up @@ -119,4 +134,23 @@ describe('command telemetry lifecycle', () => {
expect(stack).not.toMatch(/\/Users\/[^/]+\//); // POSIX home dir collapsed to ~
expect(stack).not.toContain(repoRoot);
}, 20_000);

it('emits zero events when the saved preference is opted out', () => {
seedPreferences({ telemetry: { optedOut: true } });
// Clear the forced WORKOS_TELEMETRY so the saved preference is honored
// (an empty string is not the tri-state 'true'/'false', so it falls through).
const { events } = runCli(['organization', 'create'], { WORKOS_TELEMETRY: '' });
expect(events).toHaveLength(0);
}, 20_000);

it('env WORKOS_TELEMETRY=true overrides an opted-out preference (event IS emitted)', () => {
seedPreferences({ telemetry: { optedOut: true } });
const { events } = runCli(['organization', 'create'], { WORKOS_TELEMETRY: 'true' });
expect(events.find((e) => e.type === 'command')).toBeDefined();
}, 20_000);

it('env WORKOS_TELEMETRY=false suppresses events even when not opted out', () => {
const { events } = runCli(['organization', 'create'], { WORKOS_TELEMETRY: 'false' });
expect(events).toHaveLength(0);
}, 20_000);
});
54 changes: 52 additions & 2 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import { registerSubcommand } from './utils/register-subcommand.js';
import { installCrashReporter, sanitizeMessage } from './utils/crash-reporter.js';
import { installStoreForward, recoverPendingEvents } from './utils/telemetry-store-forward.js';
import { loadDeviceId } from './lib/device-id.js';
import { loadPreferences, isTelemetryEnabled } from './lib/preferences.js';
import { maybeShowTelemetryNotice } from './lib/telemetry-notice.js';
import {
resolveCanonicalName,
resolveCommandNameFromRawArgs,
Expand All @@ -56,7 +58,6 @@ import {
} from './utils/command-telemetry.js';
import { CliExit } from './utils/cli-exit.js';
import { telemetryClient } from './utils/telemetry-client.js';
import { WORKOS_TELEMETRY_ENABLED } from './lib/constants.js';
import { ExitCode } from './utils/exit-codes.js';
import { analytics } from './utils/analytics.js';

Expand All @@ -71,6 +72,12 @@ if (process.env.WORKOS_DEBUG === '1') {
// Must be before yargs so crashes during startup are captured.
installCrashReporter();
installStoreForward();
// Prewarm the telemetry opt-out preference before init: initForNonInstaller()
// checks isEnabled() (which reads the preference), and session/command events
// may fire shortly after. The sync getPreferences() fallback makes correctness
// ordering-independent, but prewarming keeps the synchronous event path off
// blocking fs IO (same rationale as the device-id prewarm).
await loadPreferences();
analytics.initForNonInstaller();
// Prewarm the device id off the blocking-fs path so the synchronous telemetry
// event path reads it from cache. Cheap (a tiny file read); awaited so it is
Expand Down Expand Up @@ -275,6 +282,16 @@ async function runCli(): Promise<void> {
const commandParts = (argv._ as string[]) || [];
commandName = resolveCanonicalName(commandParts);
})
.middleware((argv) => {
// First-run, stderr-only notice that telemetry is being collected.
// Skip while the user is actively managing telemetry, and on the
// empty/root command (bare `--help` / `--version` / `$0`). The notice
// is self-guarded — it no-ops in json mode, when already shown, when
// opted out, and after the first display this session.
const command = String(argv._?.[0] ?? '');
if (command === 'telemetry' || command === '') return;
maybeShowTelemetryNotice();
})
.middleware(async (argv) => {
// Warn about unclaimed environments before management commands.
// Excluded: auth/claim/install/dashboard handle their own credential flows;
Expand Down Expand Up @@ -337,6 +354,39 @@ async function runCli(): Promise<void> {
);
return yargs.demandCommand(1, 'Please specify an auth subcommand').strict();
})
.command('telemetry', 'Manage telemetry collection (opt-out, opt-in, status)', (yargs) => {
registerSubcommand(
yargs,
'opt-out',
'Disable telemetry collection (persists across runs)',
(y) => y,
async () => {
const { runTelemetryOptOut } = await import('./commands/telemetry.js');
await runTelemetryOptOut();
},
);
registerSubcommand(
yargs,
'opt-in',
'Re-enable telemetry collection',
(y) => y,
async () => {
const { runTelemetryOptIn } = await import('./commands/telemetry.js');
await runTelemetryOptIn();
},
);
registerSubcommand(
yargs,
'status',
'Show whether telemetry is enabled and why',
(y) => y,
async () => {
const { runTelemetryStatus } = await import('./commands/telemetry.js');
await runTelemetryStatus();
},
);
return yargs.demandCommand(1, 'Please specify a telemetry subcommand').strict();
})
.command('skills', 'Manage WorkOS skills for coding agents (Claude Code, Codex, Cursor, Goose)', (yargs) => {
registerSubcommand(
yargs,
Expand Down Expand Up @@ -2575,7 +2625,7 @@ async function runCli(): Promise<void> {
.alias('version', 'v')
.wrap(process.stdout.isTTY && process.stdout.columns ? process.stdout.columns : 80);

const shouldSkipTelemetry = () => !WORKOS_TELEMETRY_ENABLED || SKIP_TELEMETRY_COMMANDS.has(commandName.split('.')[0]);
const shouldSkipTelemetry = () => !isTelemetryEnabled() || SKIP_TELEMETRY_COMMANDS.has(commandName.split('.')[0]);
let commandOutcome:
| {
success: boolean;
Expand Down
67 changes: 62 additions & 5 deletions src/commands/debug.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ vi.mock('../lib/config-store.js', () => ({
diagnoseConfig: (...args: unknown[]) => mockDiagnoseConfig(...args),
}));

// Mock preferences store
const mockClearPreferences = vi.fn();
const mockGetPreferencesPath = vi.fn(() => '/home/user/.workos/preferences.json');
const mockGetTelemetrySource = vi.fn(() => 'default');
const mockIsNoticeShown = vi.fn(() => false);
const mockIsTelemetryEnabled = vi.fn(() => true);
const mockIsTelemetryOptedOut = vi.fn(() => false);

vi.mock('../lib/preferences.js', () => ({
clearPreferences: (...args: unknown[]) => mockClearPreferences(...args),
getPreferencesPath: (...args: unknown[]) => mockGetPreferencesPath(...args),
getTelemetrySource: (...args: unknown[]) => mockGetTelemetrySource(...args),
isNoticeShown: (...args: unknown[]) => mockIsNoticeShown(...args),
isTelemetryEnabled: (...args: unknown[]) => mockIsTelemetryEnabled(...args),
isTelemetryOptedOut: (...args: unknown[]) => mockIsTelemetryOptedOut(...args),
}));

// Mock output
let jsonMode = false;
vi.mock('../utils/output.js', () => ({
Expand Down Expand Up @@ -212,6 +229,31 @@ describe('debug commands', () => {
expect(output).toContain('keyring');
});

it('reports telemetry status (human + JSON)', async () => {
mockGetCredentials.mockReturnValue(makeCreds());
mockGetConfig.mockReturnValue(makeConfig());
mockIsTokenExpired.mockReturnValue(false);
mockIsTelemetryEnabled.mockReturnValue(false);
mockIsTelemetryOptedOut.mockReturnValue(true);
mockGetTelemetrySource.mockReturnValue('preference');
mockIsNoticeShown.mockReturnValue(true);

await runDebugState({ showSecrets: false });
expect(consoleOutput.join('\n')).toContain('Telemetry');

consoleOutput.length = 0;
jsonMode = true;
await runDebugState({ showSecrets: false });
const parsed = JSON.parse(consoleOutput[0]);
expect(parsed.telemetry).toEqual({
enabled: false,
optedOut: true,
source: 'preference',
noticeShown: true,
});
expect(parsed.storage.preferencesPath).toBeDefined();
});

it('shows file source when insecure storage', async () => {
mockGetCredentials.mockReturnValue(makeCreds());
mockGetConfig.mockReturnValue(makeConfig());
Expand All @@ -234,48 +276,62 @@ describe('debug commands', () => {
});

describe('debug reset', () => {
it('clears both credentials and config by default', async () => {
it('clears credentials, config, and preferences by default', async () => {
mockConfirm.mockResolvedValue(true);

await runDebugReset({ force: false, credentialsOnly: false, configOnly: false });

expect(mockClearCredentials).toHaveBeenCalled();
expect(mockClearConfig).toHaveBeenCalled();
expect(mockClearPreferences).toHaveBeenCalled();
});

it('--credentials-only clears only credentials', async () => {
it('--credentials-only clears only credentials, leaving preferences intact', async () => {
mockConfirm.mockResolvedValue(true);

await runDebugReset({ force: false, credentialsOnly: true, configOnly: false });

expect(mockClearCredentials).toHaveBeenCalled();
expect(mockClearConfig).not.toHaveBeenCalled();
expect(mockClearPreferences).not.toHaveBeenCalled();
});

it('--config-only clears only config', async () => {
it('--config-only clears config and preferences, leaving credentials intact', async () => {
mockConfirm.mockResolvedValue(true);

await runDebugReset({ force: false, credentialsOnly: false, configOnly: true });

expect(mockClearConfig).toHaveBeenCalled();
expect(mockClearPreferences).toHaveBeenCalled();
expect(mockClearCredentials).not.toHaveBeenCalled();
});

it('--force skips confirmation', async () => {
it('--force skips confirmation and clears all three', async () => {
await runDebugReset({ force: true, credentialsOnly: false, configOnly: false });

expect(mockConfirm).not.toHaveBeenCalled();
expect(mockClearCredentials).toHaveBeenCalled();
expect(mockClearConfig).toHaveBeenCalled();
expect(mockClearPreferences).toHaveBeenCalled();
});

it('both --credentials-only and --config-only clears both', async () => {
it('both --credentials-only and --config-only clears everything', async () => {
mockConfirm.mockResolvedValue(true);

await runDebugReset({ force: false, credentialsOnly: true, configOnly: true });

expect(mockClearCredentials).toHaveBeenCalled();
expect(mockClearConfig).toHaveBeenCalled();
expect(mockClearPreferences).toHaveBeenCalled();
});

it('lists preferences as a cleared target in the confirmation prompt', async () => {
mockConfirm.mockResolvedValue(true);

await runDebugReset({ force: false, credentialsOnly: false, configOnly: false });

const promptArg = mockConfirm.mock.calls[0][0] as { message: string };
expect(promptArg.message).toContain('preferences');
});

it('errors in agent/CI mode without --force', async () => {
Expand All @@ -295,6 +351,7 @@ describe('debug commands', () => {
expect(parsed.cleared).toBe(true);
expect(parsed.credentials).toBe(true);
expect(parsed.config).toBe(true);
expect(parsed.preferences).toBe(true);
});
});

Expand Down
Loading
Loading