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
5 changes: 5 additions & 0 deletions .changeset/telemetry-enabled-config.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

</details>

Expand Down
11 changes: 8 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
31 changes: 30 additions & 1 deletion src/core/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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.
Expand Down Expand Up @@ -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` };
}
Expand Down
12 changes: 12 additions & 0 deletions src/core/global-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>;
Expand All @@ -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 = {
Expand Down
18 changes: 5 additions & 13 deletions src/core/version-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
Expand All @@ -38,14 +28,16 @@ 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;
if (process.env.DO_NOT_TRACK === '1') return false;
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;
}

Expand Down
7 changes: 3 additions & 4 deletions src/telemetry/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 21 additions & 8 deletions src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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
Expand Down Expand Up @@ -60,10 +63,15 @@ async function safeTelemetryFetch(url: string, options: RequestInit): Promise<Re
/**
* Check if telemetry is enabled.
*
* Disabled when:
* - OPENSPEC_TELEMETRY=0
* - DO_NOT_TRACK=1
* - CI=true (any CI environment)
* Precedence (first match wins):
* 1. OPENSPEC_TELEMETRY=0 → disabled
* 2. DO_NOT_TRACK=1 → disabled
* 3. CI set to a truthy/on value → disabled (same rule as version-check)
* 4. global config telemetry.enabled === false → disabled
* 5. otherwise enabled (unset config means on; opt-out model)
*
* Kept synchronous so call sites need not become async. Reads config via
* sync getGlobalConfig() rather than async getTelemetryConfig().
*/
export function isTelemetryEnabled(): boolean {
// Check explicit opt-out
Expand All @@ -76,8 +84,13 @@ export function isTelemetryEnabled(): boolean {
return false;
}

// Auto-disable in CI environments
if (process.env.CI === 'true') {
// Auto-disable in CI environments (providers use true/1/yes/…)
if (isCiEnvironment()) {
return false;
}

// Global config opt-out (env/CI remain hard overrides above)
if (getGlobalConfig().telemetry?.enabled === false) {
return false;
}

Expand Down Expand Up @@ -177,7 +190,7 @@ export async function maybeShowTelemetryNotice(): Promise<void> {

// 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
Expand Down
19 changes: 19 additions & 0 deletions src/utils/ci.ts
Original file line number Diff line number Diff line change
@@ -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());
}
63 changes: 63 additions & 0 deletions test/commands/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
38 changes: 38 additions & 0 deletions test/core/config-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading