diff --git a/.changeset/telemetry-enabled-config.md b/.changeset/telemetry-enabled-config.md new file mode 100644 index 0000000000..0d9e29db14 --- /dev/null +++ b/.changeset/telemetry-enabled-config.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Honor `telemetry.enabled` in global config. `false` disables anonymous telemetry and `openspec update` version checks; unset keeps telemetry enabled, and env/CI opt-outs still take precedence. diff --git a/README.md b/README.md index 697dbccc95..5470346a58 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,9 @@ OpenSpec collects anonymous usage stats. We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI. -**Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1` +**Opt-out (any one is enough):** +- `openspec config set telemetry.enabled false` (global config; unset means on) +- `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1` (env overrides config) diff --git a/docs/cli.md b/docs/cli.md index b90951c40a..0cb7666c1e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1123,7 +1123,7 @@ openspec config list # Get a specific value openspec config get telemetry.enabled -# Set a value +# Set a value (disable anonymous usage telemetry) openspec config set telemetry.enabled false # Set a string value explicitly @@ -1149,6 +1149,11 @@ openspec config profile openspec config profile core ``` +**Telemetry opt-out:** `telemetry.enabled` defaults to on when unset (opt-out model). +Set it to `false` to disable anonymous usage stats and the `openspec update` version check. +Environment variables take precedence over config: `OPENSPEC_TELEMETRY=0`, `DO_NOT_TRACK=1`, +and a truthy `CI` value (e.g. `true`/`1`/`yes`) always disable telemetry regardless of the config value. + `openspec config profile` starts with a current-state summary, then lets you choose: - Change delivery + workflows - Change delivery only @@ -1258,8 +1263,8 @@ openspec completion uninstall | Variable | Description | |----------|-------------| -| `OPENSPEC_TELEMETRY` | Set to `0` to disable telemetry and the `openspec update` version check | -| `DO_NOT_TRACK` | Set to `1` to disable telemetry and the `openspec update` version check (standard DNT signal) | +| `OPENSPEC_TELEMETRY` | Set to `0` to disable telemetry and the `openspec update` version check (overrides `telemetry.enabled` in global config) | +| `DO_NOT_TRACK` | Set to `1` to disable telemetry and the `openspec update` version check (standard DNT signal; overrides config) | | `OPENSPEC_CONCURRENCY` | Default concurrency for bulk validation (default: 6) | | `EDITOR` or `VISUAL` | Editor for `openspec config edit` | | `NO_COLOR` | Disable color output when set | diff --git a/src/core/config-schema.ts b/src/core/config-schema.ts index b05b3aa47c..eebfa01fc4 100644 --- a/src/core/config-schema.ts +++ b/src/core/config-schema.ts @@ -27,6 +27,14 @@ export const GlobalConfigSchema = z .describe( 'Store id used as fallback root when no explicit --store, local root, or project-level store: pointer resolves' ), + // passthrough keeps runtime-managed fields (anonymousId, noticeSeen) valid + // under CLI validate when users only set telemetry.enabled. + telemetry: z + .object({ + enabled: z.boolean().optional(), + }) + .passthrough() + .optional(), }) .passthrough(); @@ -41,7 +49,15 @@ export const DEFAULT_CONFIG: GlobalConfigType = { delivery: 'both', }; -const KNOWN_TOP_LEVEL_KEYS = new Set([...Object.keys(DEFAULT_CONFIG), 'workflows', 'defaultStore']); +const KNOWN_TOP_LEVEL_KEYS = new Set([ + ...Object.keys(DEFAULT_CONFIG), + 'workflows', + 'defaultStore', + 'telemetry', +]); + +/** Nested keys users may set under `telemetry` via the CLI. */ +const TELEMETRY_SETTABLE_KEYS = new Set(['enabled']); /** * Key segments that would reach the prototype chain instead of the config object. @@ -89,6 +105,19 @@ export function validateConfigKeyPath(path: string): { valid: boolean; reason?: return { valid: true }; } + if (rootKey === 'telemetry') { + if (rawKeys.length === 1) { + return { valid: false, reason: 'Set nested keys under telemetry (e.g. telemetry.enabled)' }; + } + if (rawKeys.length !== 2 || !TELEMETRY_SETTABLE_KEYS.has(rawKeys[1])) { + return { + valid: false, + reason: `Unknown telemetry key "${rawKeys.slice(1).join('.')}" (allowed: enabled)`, + }; + } + return { valid: true }; + } + if (rawKeys.length > 1) { return { valid: false, reason: `"${rootKey}" does not support nested keys` }; } diff --git a/src/core/global-config.ts b/src/core/global-config.ts index 97ebebdc0c..81986d8be7 100644 --- a/src/core/global-config.ts +++ b/src/core/global-config.ts @@ -11,6 +11,16 @@ export const GLOBAL_DATA_DIR_NAME = 'openspec'; export type Profile = 'core' | 'custom'; export type Delivery = 'both' | 'skills' | 'commands'; +/** Telemetry section of global config (identity + opt-out). */ +export interface TelemetryConfig { + /** When false, telemetry is disabled. Unset means enabled (opt-out model). */ + enabled?: boolean; + /** Anonymous random UUID; no relation to the user. */ + anonymousId?: string; + /** Whether the first-run telemetry notice has been shown. */ + noticeSeen?: boolean; +} + // TypeScript interfaces export interface GlobalConfig { featureFlags?: Record; @@ -24,6 +34,8 @@ export interface GlobalConfig { defaultStore?: string; /** Workset opener rows (slice 7.1); hand-edited, validated on use. */ openers?: unknown; + /** Anonymous usage analytics settings and identity. */ + telemetry?: TelemetryConfig; } const DEFAULT_CONFIG: GlobalConfig = { diff --git a/src/core/version-check.ts b/src/core/version-check.ts index 5033e3bb6e..fd5b05c564 100644 --- a/src/core/version-check.ts +++ b/src/core/version-check.ts @@ -4,6 +4,8 @@ import https from 'https'; import path from 'path'; import { createRequire } from 'module'; import chalk from 'chalk'; +import { isCiEnvironment } from '../utils/ci.js'; +import { getGlobalConfig } from './global-config.js'; const require = createRequire(import.meta.url); const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json'); @@ -14,18 +16,6 @@ const MAX_RESPONSE_BYTES = 256 * 1024; const VERSION_PROBE_TIMEOUT_MS = 5000; const MAX_REDIRECTS = 3; -/** - * `CI` set to anything meaningful means CI. Providers use "true", "1", "yes"; - * only an explicit off-value counts as "not CI", so a value we do not know - * still suppresses the request rather than surprising a build. - */ -const CI_DISABLED_VALUES = new Set(['', 'false', '0', 'no', 'off']); - -function isCiEnvironment(): boolean { - const value = process.env.CI; - return value !== undefined && !CI_DISABLED_VALUES.has(value.trim().toLowerCase()); -} - /** * A version we are willing to print. The registry only ever serves SemVer here, * so anything else is either a broken mirror or a hostile response — and since @@ -38,7 +28,7 @@ const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\ * The check is opt-out and must never get in the way: no network in CI or * tests, an explicit escape hatch for anyone offline or air-gapped, and the * same privacy signals telemetry already honors — a user who set DO_NOT_TRACK - * did not agree to a different outbound request. + * or telemetry.enabled false did not agree to a different outbound request. */ function isCheckEnabled(): boolean { if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined) return false; @@ -46,6 +36,8 @@ function isCheckEnabled(): boolean { if (process.env.OPENSPEC_TELEMETRY === '0') return false; if (isCiEnvironment()) return false; if (process.env.NODE_ENV === 'test') return false; + // Same config opt-out as telemetry (env remains the hard override above). + if (getGlobalConfig().telemetry?.enabled === false) return false; return true; } diff --git a/src/telemetry/config.ts b/src/telemetry/config.ts index 5bad282d97..f994cfacd0 100644 --- a/src/telemetry/config.ts +++ b/src/telemetry/config.ts @@ -9,16 +9,15 @@ import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, + type TelemetryConfig, } from '../core/global-config.js'; // Constants export const CONFIG_DIR_NAME = GLOBAL_CONFIG_DIR_NAME; export const CONFIG_FILE_NAME = GLOBAL_CONFIG_FILE_NAME; -export interface TelemetryConfig { - anonymousId?: string; - noticeSeen?: boolean; -} +/** Re-export shared telemetry section type (single source of truth in global-config). */ +export type { TelemetryConfig }; export interface GlobalConfig { telemetry?: TelemetryConfig; diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index d496dee8c6..fce6a67534 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -4,7 +4,8 @@ * Privacy-first design: * - Only tracks command name and version * - No arguments, file paths, or content - * - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1 + * - Opt-out via OPENSPEC_TELEMETRY=0, DO_NOT_TRACK=1, or + * `openspec config set telemetry.enabled false` * - Auto-disabled in CI environments * - Anonymous ID is a random UUID with no relation to the user * @@ -19,6 +20,8 @@ * versions and broke installs (#1390). */ import { randomUUID } from 'crypto'; +import { getGlobalConfig } from '../core/global-config.js'; +import { isCiEnvironment } from '../utils/ci.js'; import { getTelemetryConfig, updateTelemetryConfig } from './config.js'; // PostHog API key - public key for client-side analytics @@ -60,10 +63,15 @@ async function safeTelemetryFetch(url: string, options: RequestInit): Promise { // Display notice console.log( - 'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0' + 'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0 or openspec config set telemetry.enabled false' ); // Mark as seen diff --git a/src/utils/ci.ts b/src/utils/ci.ts new file mode 100644 index 0000000000..87be4565a1 --- /dev/null +++ b/src/utils/ci.ts @@ -0,0 +1,19 @@ +/** + * CI environment detection shared by telemetry and the version check. + * + * Providers set CI to "true", "1", "yes", etc. Only an explicit off-value + * counts as "not CI", so an unknown value still suppresses outbound requests + * rather than surprising a build. + */ + +const CI_DISABLED_VALUES = new Set(['', 'false', '0', 'no', 'off']); + +/** + * True when `CI` is set to anything other than an explicit off-value. + */ +export function isCiEnvironment( + env: NodeJS.ProcessEnv = process.env +): boolean { + const value = env.CI; + return value !== undefined && !CI_DISABLED_VALUES.has(value.trim().toLowerCase()); +} diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index 1a6c8d8cea..92096d266e 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -129,6 +129,53 @@ describe('config command integration', () => { await runConfigCommand(['unset', 'defaultStore']); expect(getGlobalConfig().defaultStore).toBeUndefined(); }); + + it('should set, get, and unset telemetry.enabled without wiping identity fields', async () => { + const { getGlobalConfigDir, getGlobalConfig } = await import('../../src/core/global-config.js'); + const configDir = getGlobalConfigDir(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + telemetry: { anonymousId: 'keep-id', noticeSeen: true }, + }) + ); + + await runConfigCommand(['set', 'telemetry.enabled', 'false']); + expect(consoleLogSpy).toHaveBeenCalledWith('Set telemetry.enabled = false'); + expect(getGlobalConfig().telemetry).toEqual({ + anonymousId: 'keep-id', + noticeSeen: true, + enabled: false, + }); + + await runConfigCommand(['get', 'telemetry.enabled']); + expect(consoleLogSpy).toHaveBeenCalledWith('false'); + + await runConfigCommand(['unset', 'telemetry.enabled']); + expect(getGlobalConfig().telemetry).toEqual({ + anonymousId: 'keep-id', + noticeSeen: true, + }); + }); + + it('should reject unknown nested telemetry keys without --allow-unknown', async () => { + const previousExitCode = process.exitCode; + process.exitCode = undefined; + + try { + await runConfigCommand(['set', 'telemetry.anonymousId', 'x']); + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid configuration key "telemetry.anonymousId"') + ); + } finally { + process.exitCode = previousExitCode; + } + }); }); describe('config command shell completion registry', () => { @@ -237,6 +284,22 @@ describe('config key validation', () => { const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); expect(validateConfigKeyPath('defaultStore.nested').valid).toBe(false); }); + + it('allows telemetry.enabled', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('telemetry.enabled').valid).toBe(true); + }); + + it('rejects bare telemetry key', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('telemetry').valid).toBe(false); + }); + + it('rejects unknown nested telemetry keys', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('telemetry.anonymousId').valid).toBe(false); + expect(validateConfigKeyPath('telemetry.foo').valid).toBe(false); + }); }); describe('config profile command', () => { diff --git a/test/core/config-schema.test.ts b/test/core/config-schema.test.ts index b539975dac..4089bf01f0 100644 --- a/test/core/config-schema.test.ts +++ b/test/core/config-schema.test.ts @@ -360,6 +360,44 @@ describe('config-schema', () => { const result = GlobalConfigSchema.parse({}); expect(result.featureFlags).toEqual({}); }); + + it('should accept telemetry.enabled with passthrough identity fields', () => { + const result = GlobalConfigSchema.safeParse({ + telemetry: { + enabled: false, + anonymousId: 'keep-me', + noticeSeen: true, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.telemetry).toEqual({ + enabled: false, + anonymousId: 'keep-me', + noticeSeen: true, + }); + } + }); + + it('should reject non-boolean telemetry.enabled', () => { + const result = GlobalConfigSchema.safeParse({ + telemetry: { enabled: 'nope' }, + }); + expect(result.success).toBe(false); + }); + }); + + describe('validateConfigKeyPath telemetry', () => { + it('allows telemetry.enabled only', () => { + expect(validateConfigKeyPath('telemetry.enabled')).toEqual({ valid: true }); + }); + + it('rejects bare telemetry and unknown leaves', () => { + expect(validateConfigKeyPath('telemetry').valid).toBe(false); + expect(validateConfigKeyPath('telemetry.anonymousId').valid).toBe(false); + expect(validateConfigKeyPath('telemetry.noticeSeen').valid).toBe(false); + expect(validateConfigKeyPath('telemetry.enabled.extra').valid).toBe(false); + }); }); describe('DEFAULT_CONFIG', () => { diff --git a/test/core/version-check.test.ts b/test/core/version-check.test.ts index 3f3231649e..585b6d375b 100644 --- a/test/core/version-check.test.ts +++ b/test/core/version-check.test.ts @@ -278,6 +278,30 @@ describe('getAvailableCliUpdate', () => { expect(requests).toHaveLength(0); }); + it('sends nothing when telemetry.enabled is false in global config', async () => { + const xdgHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-vc-telemetry-')); + const previousXdg = process.env.XDG_CONFIG_HOME; + try { + process.env.XDG_CONFIG_HOME = xdgHome; + const configDir = path.join(xdgHome, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ telemetry: { enabled: false } }) + ); + + await expect(getAvailableCliUpdate()).resolves.toBeNull(); + expect(requests).toHaveLength(0); + } finally { + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdg; + } + fs.rmSync(xdgHome, { recursive: true, force: true }); + } + }); + it('still runs when CI is explicitly switched off', async () => { for (const value of ['false', '0', 'no', '']) { process.env.CI = value; diff --git a/test/telemetry/config.test.ts b/test/telemetry/config.test.ts index d22d138d40..383eedb6a1 100644 --- a/test/telemetry/config.test.ts +++ b/test/telemetry/config.test.ts @@ -292,5 +292,43 @@ describe('telemetry/config', () => { expect(parsed.telemetry.anonymousId).toBe('existing-id'); expect(parsed.telemetry.noticeSeen).toBe(true); }); + + it('should preserve anonymousId and noticeSeen when setting enabled', async () => { + const configDir = defaultConfigDir(); + const configPath = defaultConfigPath(); + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + telemetry: { anonymousId: 'keep-id', noticeSeen: true }, + })); + + await updateTelemetryConfig({ enabled: false }); + + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + expect(parsed.telemetry).toEqual({ + anonymousId: 'keep-id', + noticeSeen: true, + enabled: false, + }); + }); + + it('should preserve enabled when updating noticeSeen', async () => { + const configDir = defaultConfigDir(); + const configPath = defaultConfigPath(); + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + telemetry: { enabled: false, anonymousId: 'keep-id' }, + })); + + await updateTelemetryConfig({ noticeSeen: true }); + + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + expect(parsed.telemetry).toEqual({ + enabled: false, + anonymousId: 'keep-id', + noticeSeen: true, + }); + }); }); }); diff --git a/test/telemetry/index.test.ts b/test/telemetry/index.test.ts index 6ff9b13c86..b3b21f7aa9 100644 --- a/test/telemetry/index.test.ts +++ b/test/telemetry/index.test.ts @@ -18,8 +18,11 @@ describe('telemetry/index', () => { // Save original env originalEnv = { ...process.env }; - // Mock HOME to point to temp dir + // Isolate global config to the temp dir via XDG (same path getGlobalConfig uses) + process.env.XDG_CONFIG_HOME = tempDir; process.env.HOME = tempDir; + process.env.USERPROFILE = tempDir; + process.env.APPDATA = path.join(tempDir, 'appdata'); // Clear all mocks vi.clearAllMocks(); @@ -55,6 +58,16 @@ describe('telemetry/index', () => { delete process.env.CI; } + /** Write an isolated global telemetry section for synchronous gate tests. */ + function writeTelemetryConfig(telemetry: Record): void { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ telemetry }) + ); + } + describe('isTelemetryEnabled', () => { it('should return false when OPENSPEC_TELEMETRY=0', () => { process.env.OPENSPEC_TELEMETRY = '0'; @@ -71,6 +84,23 @@ describe('telemetry/index', () => { expect(isTelemetryEnabled()).toBe(false); }); + it.each(['1', 'yes', 'TRUE', 'on'])( + 'should return false for CI=%s (same rule as version-check)', + (value) => { + process.env.CI = value; + expect(isTelemetryEnabled()).toBe(false); + } + ); + + it.each(['false', '0', 'no', 'off', ''])( + 'should return true when CI=%s (explicitly off)', + (value) => { + enableTelemetry(); + process.env.CI = value; + expect(isTelemetryEnabled()).toBe(true); + } + ); + it('should return true when no opt-out is set', () => { enableTelemetry(); expect(isTelemetryEnabled()).toBe(true); @@ -82,6 +112,52 @@ describe('telemetry/index', () => { delete process.env.CI; expect(isTelemetryEnabled()).toBe(false); }); + + it('should return false when telemetry.enabled is false in global config', () => { + enableTelemetry(); + writeTelemetryConfig({ enabled: false }); + + expect(isTelemetryEnabled()).toBe(false); + }); + + it('should return true when telemetry.enabled is missing (opt-out default)', () => { + enableTelemetry(); + writeTelemetryConfig({ anonymousId: 'id-only' }); + + expect(isTelemetryEnabled()).toBe(true); + }); + + it('should return true when telemetry.enabled is true', () => { + enableTelemetry(); + writeTelemetryConfig({ enabled: true }); + + expect(isTelemetryEnabled()).toBe(true); + }); + + it('should let OPENSPEC_TELEMETRY=0 win over telemetry.enabled true', () => { + process.env.OPENSPEC_TELEMETRY = '0'; + delete process.env.DO_NOT_TRACK; + delete process.env.CI; + writeTelemetryConfig({ enabled: true }); + + expect(isTelemetryEnabled()).toBe(false); + }); + + it('should let DO_NOT_TRACK=1 win over telemetry.enabled true', () => { + enableTelemetry(); + process.env.DO_NOT_TRACK = '1'; + writeTelemetryConfig({ enabled: true }); + + expect(isTelemetryEnabled()).toBe(false); + }); + + it('should let CI win over telemetry.enabled true', () => { + enableTelemetry(); + process.env.CI = '1'; + writeTelemetryConfig({ enabled: true }); + + expect(isTelemetryEnabled()).toBe(false); + }); }); describe('maybeShowTelemetryNotice', () => { @@ -92,6 +168,15 @@ describe('telemetry/index', () => { expect(consoleLogSpy).not.toHaveBeenCalled(); }); + + it('should not show notice when telemetry.enabled is false', async () => { + enableTelemetry(); + writeTelemetryConfig({ enabled: false }); + + await maybeShowTelemetryNotice(); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); }); describe('trackCommand', () => { @@ -104,6 +189,16 @@ describe('telemetry/index', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it('should send nothing when telemetry.enabled is false', async () => { + enableTelemetry(); + writeTelemetryConfig({ enabled: false, anonymousId: 'keep-me' }); + + await trackCommand('test', '1.0.0'); + await shutdown(); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it('should post one capture event to the batch endpoint when enabled', async () => { enableTelemetry(); diff --git a/test/utils/ci.test.ts b/test/utils/ci.test.ts new file mode 100644 index 0000000000..bf31c9a8ff --- /dev/null +++ b/test/utils/ci.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; + +import { isCiEnvironment } from '../../src/utils/ci.js'; + +describe('isCiEnvironment', () => { + it('returns false when CI is unset', () => { + expect(isCiEnvironment({})).toBe(false); + }); + + it.each(['true', '1', 'yes', 'TRUE', 'on', 'ci'])( + 'returns true for CI=%s', + (value) => { + expect(isCiEnvironment({ CI: value })).toBe(true); + } + ); + + it.each(['false', '0', 'no', 'off', '', ' FALSE '])( + 'returns false for explicit off value CI=%s', + (value) => { + expect(isCiEnvironment({ CI: value })).toBe(false); + } + ); +});