mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-02 05:24:34 +08:00
Add optional anonymous usage statistics (#468)
* chore: add proposal for PostHog analytics integration Introduces the proposal artifact for adding opt-in telemetry to OpenSpec using PostHog. Covers command tracking, feature adoption metrics, and privacy-respecting consent management. * feat: add optional anonymous usage statistics Introduces privacy-first usage analytics to help understand how OpenSpec is being used. Key privacy protections: - Only tracks command names and version (no arguments, paths, or content) - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1 - Auto-disabled in CI environments - No IP address collection (explicitly disabled) - Anonymous ID is a random UUID with no PII Uses PostHog with a reverse proxy to avoid ad blockers. First-run shows a one-line notice informing users about the collection. * docs: make telemetry section collapsible and concise
This commit is contained in:
@@ -410,6 +410,15 @@ You can always go back:
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Telemetry</strong> – OpenSpec collects anonymous usage stats (opt-out: <code>OPENSPEC_TELEMETRY=0</code>)</summary>
|
||||
|
||||
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`
|
||||
|
||||
</details>
|
||||
|
||||
## Contributing
|
||||
|
||||
- Install dependencies: `pnpm install`
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-10
|
||||
@@ -0,0 +1,175 @@
|
||||
## Context
|
||||
|
||||
OpenSpec needs usage analytics to understand adoption and inform product decisions. PostHog provides a privacy-conscious analytics platform suitable for open source projects.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Track daily/weekly/monthly active usage
|
||||
- Understand command usage patterns
|
||||
- Keep implementation minimal and privacy-respecting
|
||||
- Enable opt-out with minimal friction
|
||||
|
||||
**Non-Goals:**
|
||||
- Detailed error tracking or diagnostics
|
||||
- User identification or profiling
|
||||
- Complex event hierarchies
|
||||
- Full CLI command for telemetry management (env var sufficient for now)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Opt-Out Model
|
||||
|
||||
**Decision:** Telemetry enabled by default, opt-out via environment variable.
|
||||
|
||||
```bash
|
||||
OPENSPEC_TELEMETRY=0 # Disable telemetry
|
||||
DO_NOT_TRACK=1 # Industry standard, also respected
|
||||
```
|
||||
|
||||
Auto-disabled when `CI=true` is detected.
|
||||
|
||||
**Rationale:**
|
||||
- Opt-in typically yields ~3% participation—not enough for meaningful data
|
||||
- Understanding usage patterns requires statistically significant sample sizes
|
||||
- Environment variable opt-out is simple and immediate
|
||||
- Respecting `DO_NOT_TRACK` follows industry convention
|
||||
|
||||
**Alternatives considered:**
|
||||
- Opt-in only - Insufficient data for product decisions
|
||||
- Config file setting - More complex, env var sufficient for MVP
|
||||
- Full `openspec telemetry` command - Can add later if users request
|
||||
|
||||
### Event Design
|
||||
|
||||
**Decision:** Single event type with minimal properties.
|
||||
|
||||
```typescript
|
||||
{
|
||||
event: 'command_executed',
|
||||
properties: {
|
||||
command: 'init', // Command name only
|
||||
version: '1.2.3' // OpenSpec version
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Answers the core questions: how much usage, which commands are popular
|
||||
- PostHog derives DAU/WAU/MAU from anonymous user counts over time
|
||||
- No arguments, paths, or content—clean privacy story
|
||||
- Easy to explain in disclosure notice
|
||||
|
||||
**Not tracked:**
|
||||
- Command arguments
|
||||
- File paths or contents
|
||||
- Error messages or stack traces
|
||||
- Project names or spec content
|
||||
- IP addresses (`$ip: null` explicitly set)
|
||||
|
||||
### Anonymous ID
|
||||
|
||||
**Decision:** Random UUID, lazily generated on first telemetry send, stored in global config.
|
||||
|
||||
```typescript
|
||||
// ~/.config/openspec/config.json
|
||||
{
|
||||
"telemetry": {
|
||||
"anonymousId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Random UUID has no relation to the person—can't be reversed
|
||||
- Stored in config so same user = same ID across sessions (needed for DAU/WAU/MAU)
|
||||
- Lazy generation means no ID created if user opts out before first command
|
||||
- User can delete config to reset identity
|
||||
|
||||
**Alternatives considered:**
|
||||
- Machine-derived hash (hostname, MAC) - Feels invasive, fingerprint-like
|
||||
- Per-session UUID - Breaks user counting metrics entirely
|
||||
|
||||
### SDK Configuration
|
||||
|
||||
**Decision:** PostHog Node SDK with immediate flush, shutdown on exit.
|
||||
|
||||
```typescript
|
||||
const posthog = new PostHog(API_KEY, {
|
||||
flushAt: 1, // Send immediately, don't batch
|
||||
flushInterval: 0 // No timer-based flushing
|
||||
});
|
||||
|
||||
// Before CLI exits
|
||||
await posthog.shutdown();
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- CLI processes are short-lived; batching would lose events
|
||||
- `flushAt: 1` ensures each event sends immediately
|
||||
- `shutdown()` guarantees flush before process exit
|
||||
- Adds ~100-300ms to exit—negligible for typical CLI workflows
|
||||
|
||||
**Error handling:**
|
||||
- Network failures silently ignored (telemetry shouldn't break CLI)
|
||||
- `shutdown()` wrapped in try/catch
|
||||
|
||||
### Hook Location
|
||||
|
||||
**Decision:** Commander.js `preAction` and `postAction` hooks.
|
||||
|
||||
```typescript
|
||||
program
|
||||
.hook('preAction', (thisCommand) => {
|
||||
maybeShowTelemetryNotice();
|
||||
trackCommand(thisCommand.name(), VERSION);
|
||||
})
|
||||
.hook('postAction', async () => {
|
||||
await shutdown();
|
||||
});
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Centralized—one place for all telemetry logic
|
||||
- Automatic—new commands get tracked without code changes
|
||||
- Clean separation—command handlers don't know about telemetry
|
||||
|
||||
**Subcommand handling:**
|
||||
- Track full command path for nested commands (e.g., `change:apply`)
|
||||
|
||||
### First-Run Notice
|
||||
|
||||
**Decision:** One-liner on first command ever, stored "seen" flag in config.
|
||||
|
||||
```
|
||||
Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- First command (not just `init`) ensures notice is always seen
|
||||
- Non-blocking—no prompt, just informational
|
||||
- One-liner is visible but not intrusive
|
||||
- Storing "seen" in config prevents repeated display
|
||||
|
||||
**Config after first run:**
|
||||
```json
|
||||
{
|
||||
"telemetry": {
|
||||
"anonymousId": "...",
|
||||
"noticeSeen": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Users prefer opt-in | Clear disclosure, trivial opt-out, transparent about what's collected |
|
||||
| GDPR concerns | No personal data, no IP, user can delete config |
|
||||
| Slows CLI exit by ~200ms | Negligible for most workflows; can optimize if needed |
|
||||
| PostHog outage affects CLI | Fire-and-forget with timeout; failures are silent |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None—design is intentionally minimal. Future enhancements (dedicated command, workflow tracking) can be added based on user feedback.
|
||||
@@ -0,0 +1,37 @@
|
||||
## Why
|
||||
|
||||
OpenSpec currently has no visibility into how the tool is being used. Without analytics, we cannot:
|
||||
- Understand which commands and features are most valuable to users
|
||||
- Measure adoption and usage patterns
|
||||
- Make data-driven decisions about product development
|
||||
|
||||
Adding PostHog analytics enables product insights while respecting user privacy through transparent, opt-out telemetry.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add PostHog Node.js SDK as a dependency
|
||||
- Implement telemetry system with environment variable opt-out
|
||||
- Track command usage (command name and version only)
|
||||
- Show first-run notice informing users about telemetry
|
||||
- Store anonymous ID in global config (`~/.config/openspec/config.json`)
|
||||
- Respect `DO_NOT_TRACK` and `OPENSPEC_TELEMETRY=0` environment variables
|
||||
- Auto-disable in CI environments
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `telemetry`: Anonymous usage analytics using PostHog. Covers command tracking, opt-out controls, and first-run disclosure notice.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `global-config`: Add telemetry state storage (anonymous ID, notice seen flag)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Dependencies**: Add `posthog-node` package
|
||||
- **Privacy**: Opt-out via env var, no personal data collected, clear disclosure
|
||||
- **Configuration**: New global config fields for telemetry state
|
||||
- **Network**: Async event sending with flush on exit (~100-300ms added)
|
||||
- **CI/CD**: Telemetry auto-disabled when `CI=true`
|
||||
- **Documentation**: Update README with telemetry disclosure
|
||||
@@ -0,0 +1,21 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Global configuration storage
|
||||
The system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.
|
||||
|
||||
#### Scenario: Initial config creation
|
||||
- **WHEN** no global config file exists
|
||||
- **AND** the first telemetry event is about to be sent
|
||||
- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration
|
||||
|
||||
#### Scenario: Telemetry config structure
|
||||
- **WHEN** reading or writing telemetry configuration
|
||||
- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields
|
||||
|
||||
#### Scenario: Config file format
|
||||
- **WHEN** storing configuration
|
||||
- **THEN** the system writes valid JSON that can be read and modified by users
|
||||
|
||||
#### Scenario: Existing config preservation
|
||||
- **WHEN** adding telemetry fields to an existing config file
|
||||
- **THEN** the system preserves all existing configuration fields
|
||||
@@ -0,0 +1,116 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Command execution tracking
|
||||
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.
|
||||
|
||||
#### Scenario: Standard command execution
|
||||
- **WHEN** a user runs any openspec command
|
||||
- **THEN** the system sends a `command_executed` event with `command` and `version` properties
|
||||
|
||||
#### Scenario: Subcommand execution
|
||||
- **WHEN** a user runs a nested command like `openspec change apply`
|
||||
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
|
||||
|
||||
### Requirement: Privacy-preserving event design
|
||||
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
|
||||
|
||||
#### Scenario: Command with arguments
|
||||
- **WHEN** a user runs `openspec init my-project --force`
|
||||
- **THEN** the telemetry event contains only `command: "init"` and `version: "<version>"` without arguments
|
||||
|
||||
#### Scenario: IP address exclusion
|
||||
- **WHEN** the system sends a telemetry event
|
||||
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
|
||||
|
||||
### Requirement: Environment variable opt-out
|
||||
The system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.
|
||||
|
||||
#### Scenario: OPENSPEC_TELEMETRY opt-out
|
||||
- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: DO_NOT_TRACK opt-out
|
||||
- **WHEN** `DO_NOT_TRACK=1` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: Environment variable takes precedence
|
||||
- **WHEN** the user has previously used the CLI (config exists)
|
||||
- **AND** the user sets `OPENSPEC_TELEMETRY=0`
|
||||
- **THEN** telemetry is disabled regardless of config state
|
||||
|
||||
### Requirement: CI environment auto-disable
|
||||
The system SHALL automatically disable telemetry when `CI=true` environment variable is detected.
|
||||
|
||||
#### Scenario: CI environment detection
|
||||
- **WHEN** `CI=true` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: CI with explicit enable
|
||||
- **WHEN** `CI=true` is set
|
||||
- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set
|
||||
- **THEN** telemetry remains disabled (CI takes precedence for privacy)
|
||||
|
||||
### Requirement: First-run telemetry notice
|
||||
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.
|
||||
|
||||
#### Scenario: First command execution
|
||||
- **WHEN** a user runs their first openspec command
|
||||
- **AND** telemetry is enabled
|
||||
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
|
||||
|
||||
#### Scenario: Subsequent command execution
|
||||
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
|
||||
- **THEN** the system does not display the notice
|
||||
|
||||
#### Scenario: Notice before telemetry
|
||||
- **WHEN** displaying the first-run notice
|
||||
- **THEN** the notice appears before any telemetry event is sent
|
||||
|
||||
### Requirement: Anonymous user identification
|
||||
The system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.
|
||||
|
||||
#### Scenario: First telemetry event
|
||||
- **WHEN** the first telemetry event is sent
|
||||
- **AND** no anonymousId exists in config
|
||||
- **THEN** the system generates a random UUID v4 and stores it in config
|
||||
|
||||
#### Scenario: Persistent identity
|
||||
- **WHEN** a user runs multiple commands across sessions
|
||||
- **THEN** the same anonymousId is used for all events
|
||||
|
||||
#### Scenario: Lazy generation with opt-out
|
||||
- **WHEN** a user opts out before running any command
|
||||
- **THEN** no anonymousId is ever generated or stored
|
||||
|
||||
### Requirement: Immediate event sending
|
||||
The system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.
|
||||
|
||||
#### Scenario: Event transmission timing
|
||||
- **WHEN** a command executes
|
||||
- **THEN** the telemetry event is sent immediately, not queued for batch transmission
|
||||
|
||||
### Requirement: Graceful shutdown
|
||||
The system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.
|
||||
|
||||
#### Scenario: Normal exit
|
||||
- **WHEN** a command completes successfully
|
||||
- **THEN** the system awaits `shutdown()` before exiting
|
||||
|
||||
#### Scenario: Error exit
|
||||
- **WHEN** a command fails with an error
|
||||
- **THEN** the system still awaits `shutdown()` before exiting
|
||||
|
||||
### Requirement: Silent failure handling
|
||||
The system SHALL silently ignore telemetry failures without affecting CLI functionality.
|
||||
|
||||
#### Scenario: Network failure
|
||||
- **WHEN** the telemetry request fails due to network error
|
||||
- **THEN** the CLI command completes normally without error message
|
||||
|
||||
#### Scenario: PostHog outage
|
||||
- **WHEN** PostHog service is unavailable
|
||||
- **THEN** the CLI command completes normally without error message
|
||||
|
||||
#### Scenario: Shutdown failure
|
||||
- **WHEN** `shutdown()` fails or times out
|
||||
- **THEN** the CLI exits normally without error message
|
||||
@@ -0,0 +1,47 @@
|
||||
## 1. Setup
|
||||
|
||||
- [x] 1.1 Add `posthog-node` package as a dependency
|
||||
- [x] 1.2 Create `src/telemetry/` module directory
|
||||
- [x] 1.3 Add PostHog API key configuration (environment variable or embedded)
|
||||
|
||||
## 2. Global Config
|
||||
|
||||
- [x] 2.1 Create or extend global config module for `~/.config/openspec/config.json`
|
||||
- [x] 2.2 Implement read/write functions that preserve existing config fields
|
||||
- [x] 2.3 Define telemetry config structure (`anonymousId`, `noticeSeen`)
|
||||
|
||||
## 3. Core Telemetry Module
|
||||
|
||||
- [x] 3.1 Implement `isTelemetryEnabled()` checking `OPENSPEC_TELEMETRY`, `DO_NOT_TRACK`, and `CI` env vars
|
||||
- [x] 3.2 Implement `getOrCreateAnonymousId()` with lazy UUID generation
|
||||
- [x] 3.3 Initialize PostHog client with `flushAt: 1` and `flushInterval: 0`
|
||||
- [x] 3.4 Implement `trackCommand(commandName, version)` with `$ip: null`
|
||||
- [x] 3.5 Implement `shutdown()` with try/catch for silent failure handling
|
||||
|
||||
## 4. First-Run Notice
|
||||
|
||||
- [x] 4.1 Implement `maybeShowTelemetryNotice()` function
|
||||
- [x] 4.2 Check `noticeSeen` flag before displaying notice
|
||||
- [x] 4.3 Display notice text: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
|
||||
- [x] 4.4 Update `noticeSeen` in config after first display
|
||||
|
||||
## 5. CLI Integration
|
||||
|
||||
- [x] 5.1 Add Commander.js `preAction` hook to show notice and track command
|
||||
- [x] 5.2 Add Commander.js `postAction` hook to call shutdown
|
||||
- [x] 5.3 Handle subcommand path extraction (e.g., `change:apply`)
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- [x] 6.1 Test opt-out via `OPENSPEC_TELEMETRY=0`
|
||||
- [x] 6.2 Test opt-out via `DO_NOT_TRACK=1`
|
||||
- [x] 6.3 Test auto-disable in CI environment
|
||||
- [x] 6.4 Test first-run notice display and noticeSeen persistence
|
||||
- [x] 6.5 Test anonymous ID generation and persistence
|
||||
- [x] 6.6 Test silent failure on network error (mock PostHog)
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- [x] 7.1 Add telemetry disclosure section to README
|
||||
- [x] 7.2 Document opt-out methods (`OPENSPEC_TELEMETRY=0`, `DO_NOT_TRACK=1`)
|
||||
- [x] 7.3 Document what data is collected and not collected
|
||||
@@ -4,6 +4,26 @@
|
||||
|
||||
This spec defines how OpenSpec resolves, reads, and writes user-level global configuration. It governs the `src/core/global-config.ts` module, which provides the foundation for storing user preferences, feature flags, and settings that persist across projects. The spec ensures cross-platform compatibility by following XDG Base Directory Specification with platform-specific fallbacks, and guarantees forward/backward compatibility through schema evolution rules.
|
||||
## Requirements
|
||||
### Requirement: Global configuration storage
|
||||
The system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.
|
||||
|
||||
#### Scenario: Initial config creation
|
||||
- **WHEN** no global config file exists
|
||||
- **AND** the first telemetry event is about to be sent
|
||||
- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration
|
||||
|
||||
#### Scenario: Telemetry config structure
|
||||
- **WHEN** reading or writing telemetry configuration
|
||||
- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields
|
||||
|
||||
#### Scenario: Config file format
|
||||
- **WHEN** storing configuration
|
||||
- **THEN** the system writes valid JSON that can be read and modified by users
|
||||
|
||||
#### Scenario: Existing config preservation
|
||||
- **WHEN** adding telemetry fields to an existing config file
|
||||
- **THEN** the system preserves all existing configuration fields
|
||||
|
||||
### Requirement: Global Config Directory Path
|
||||
|
||||
The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# telemetry Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
This spec defines how OpenSpec collects anonymous usage telemetry to help improve the tool. It governs the `src/telemetry/` module, which handles PostHog integration, privacy-preserving event design, user opt-out mechanisms, and first-run notice display. The spec ensures telemetry is minimal, transparent, and respects user privacy.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Command execution tracking
|
||||
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.
|
||||
|
||||
#### Scenario: Standard command execution
|
||||
- **WHEN** a user runs any openspec command
|
||||
- **THEN** the system sends a `command_executed` event with `command` and `version` properties
|
||||
|
||||
#### Scenario: Subcommand execution
|
||||
- **WHEN** a user runs a nested command like `openspec change apply`
|
||||
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
|
||||
|
||||
### Requirement: Privacy-preserving event design
|
||||
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
|
||||
|
||||
#### Scenario: Command with arguments
|
||||
- **WHEN** a user runs `openspec init my-project --force`
|
||||
- **THEN** the telemetry event contains only `command: "init"` and `version: "<version>"` without arguments
|
||||
|
||||
#### Scenario: IP address exclusion
|
||||
- **WHEN** the system sends a telemetry event
|
||||
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
|
||||
|
||||
### Requirement: Environment variable opt-out
|
||||
The system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.
|
||||
|
||||
#### Scenario: OPENSPEC_TELEMETRY opt-out
|
||||
- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: DO_NOT_TRACK opt-out
|
||||
- **WHEN** `DO_NOT_TRACK=1` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: Environment variable takes precedence
|
||||
- **WHEN** the user has previously used the CLI (config exists)
|
||||
- **AND** the user sets `OPENSPEC_TELEMETRY=0`
|
||||
- **THEN** telemetry is disabled regardless of config state
|
||||
|
||||
### Requirement: CI environment auto-disable
|
||||
The system SHALL automatically disable telemetry when `CI=true` environment variable is detected.
|
||||
|
||||
#### Scenario: CI environment detection
|
||||
- **WHEN** `CI=true` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: CI with explicit enable
|
||||
- **WHEN** `CI=true` is set
|
||||
- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set
|
||||
- **THEN** telemetry remains disabled (CI takes precedence for privacy)
|
||||
|
||||
### Requirement: First-run telemetry notice
|
||||
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.
|
||||
|
||||
#### Scenario: First command execution
|
||||
- **WHEN** a user runs their first openspec command
|
||||
- **AND** telemetry is enabled
|
||||
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
|
||||
|
||||
#### Scenario: Subsequent command execution
|
||||
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
|
||||
- **THEN** the system does not display the notice
|
||||
|
||||
#### Scenario: Notice before telemetry
|
||||
- **WHEN** displaying the first-run notice
|
||||
- **THEN** the notice appears before any telemetry event is sent
|
||||
|
||||
### Requirement: Anonymous user identification
|
||||
The system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.
|
||||
|
||||
#### Scenario: First telemetry event
|
||||
- **WHEN** the first telemetry event is sent
|
||||
- **AND** no anonymousId exists in config
|
||||
- **THEN** the system generates a random UUID v4 and stores it in config
|
||||
|
||||
#### Scenario: Persistent identity
|
||||
- **WHEN** a user runs multiple commands across sessions
|
||||
- **THEN** the same anonymousId is used for all events
|
||||
|
||||
#### Scenario: Lazy generation with opt-out
|
||||
- **WHEN** a user opts out before running any command
|
||||
- **THEN** no anonymousId is ever generated or stored
|
||||
|
||||
### Requirement: Immediate event sending
|
||||
The system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.
|
||||
|
||||
#### Scenario: Event transmission timing
|
||||
- **WHEN** a command executes
|
||||
- **THEN** the telemetry event is sent immediately, not queued for batch transmission
|
||||
|
||||
### Requirement: Graceful shutdown
|
||||
The system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.
|
||||
|
||||
#### Scenario: Normal exit
|
||||
- **WHEN** a command completes successfully
|
||||
- **THEN** the system awaits `shutdown()` before exiting
|
||||
|
||||
#### Scenario: Error exit
|
||||
- **WHEN** a command fails with an error
|
||||
- **THEN** the system still awaits `shutdown()` before exiting
|
||||
|
||||
### Requirement: Silent failure handling
|
||||
The system SHALL silently ignore telemetry failures without affecting CLI functionality.
|
||||
|
||||
#### Scenario: Network failure
|
||||
- **WHEN** the telemetry request fails due to network error
|
||||
- **THEN** the CLI command completes normally without error message
|
||||
|
||||
#### Scenario: PostHog outage
|
||||
- **WHEN** PostHog service is unavailable
|
||||
- **THEN** the CLI command completes normally without error message
|
||||
|
||||
#### Scenario: Shutdown failure
|
||||
- **WHEN** `shutdown()` fails or times out
|
||||
- **THEN** the CLI exits normally without error message
|
||||
@@ -76,6 +76,7 @@
|
||||
"commander": "^14.0.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ora": "^8.2.0",
|
||||
"posthog-node": "^5.20.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.0.17"
|
||||
}
|
||||
|
||||
Generated
+18
@@ -26,6 +26,9 @@ importers:
|
||||
ora:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
posthog-node:
|
||||
specifier: ^5.20.0
|
||||
version: 5.20.0
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
@@ -484,6 +487,9 @@ packages:
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@posthog/core@1.9.1':
|
||||
resolution: {integrity: sha512-kRb1ch2dhQjsAapZmu6V66551IF2LnCbc1rnrQqnR7ArooVyJN9KOPXre16AJ3ObJz2eTfuP7x25BMyS2Y5Exw==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.46.2':
|
||||
resolution: {integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==}
|
||||
cpu: [arm]
|
||||
@@ -1284,6 +1290,10 @@ packages:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
posthog-node@5.20.0:
|
||||
resolution: {integrity: sha512-LkR5KfrvEQTnUtNKN97VxFB00KcYG1Iz8iKg8r0e/i7f1eQhg1WSZO+Jp1B4bvtHCmdpIE4HwYbvCCzFoCyjVg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2038,6 +2048,10 @@ snapshots:
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@posthog/core@1.9.1':
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.46.2':
|
||||
optional: true
|
||||
|
||||
@@ -2808,6 +2822,10 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
posthog-node@5.20.0:
|
||||
dependencies:
|
||||
'@posthog/core': 1.9.1
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@2.8.8: {}
|
||||
|
||||
+35
-2
@@ -15,11 +15,32 @@ import { ShowCommand } from '../commands/show.js';
|
||||
import { CompletionCommand } from '../commands/completion.js';
|
||||
import { registerConfigCommand } from '../commands/config.js';
|
||||
import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js';
|
||||
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
|
||||
|
||||
const program = new Command();
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../../package.json');
|
||||
|
||||
/**
|
||||
* Get the full command path for nested commands.
|
||||
* For example: 'change show' -> 'change:show'
|
||||
*/
|
||||
function getCommandPath(command: Command): string {
|
||||
const names: string[] = [];
|
||||
let current: Command | null = command;
|
||||
|
||||
while (current) {
|
||||
const name = current.name();
|
||||
// Skip the root 'openspec' command
|
||||
if (name && name !== 'openspec') {
|
||||
names.unshift(name);
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return names.join(':') || 'openspec';
|
||||
}
|
||||
|
||||
program
|
||||
.name('openspec')
|
||||
.description('AI-native system for spec-driven development')
|
||||
@@ -28,12 +49,24 @@ program
|
||||
// Global options
|
||||
program.option('--no-color', 'Disable color output');
|
||||
|
||||
// Apply global flags before any command runs
|
||||
program.hook('preAction', (thisCommand) => {
|
||||
// Apply global flags and telemetry before any command runs
|
||||
program.hook('preAction', async (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.color === false) {
|
||||
process.env.NO_COLOR = '1';
|
||||
}
|
||||
|
||||
// Show first-run telemetry notice (if not seen)
|
||||
await maybeShowTelemetryNotice();
|
||||
|
||||
// Track command execution
|
||||
const commandPath = getCommandPath(thisCommand);
|
||||
await trackCommand(commandPath, version);
|
||||
});
|
||||
|
||||
// Shutdown telemetry after command completes
|
||||
program.hook('postAction', async () => {
|
||||
await shutdown();
|
||||
});
|
||||
|
||||
const availableToolIds = AI_TOOLS.filter((tool) => tool.available).map((tool) => tool.value);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Global configuration for telemetry state.
|
||||
* Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json
|
||||
*/
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
export interface TelemetryConfig {
|
||||
anonymousId?: string;
|
||||
noticeSeen?: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalConfig {
|
||||
telemetry?: TelemetryConfig;
|
||||
[key: string]: unknown; // Preserve other fields
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the global config file.
|
||||
* Uses ~/.config/openspec/config.json on all platforms.
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
const configDir = path.join(os.homedir(), '.config', 'openspec');
|
||||
return path.join(configDir, 'config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the global config file.
|
||||
* Returns an empty object if the file doesn't exist.
|
||||
*/
|
||||
export async function readConfig(): Promise<GlobalConfig> {
|
||||
const configPath = getConfigPath();
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
return JSON.parse(content) as GlobalConfig;
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
// If parse fails or other error, return empty config
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to the global config file.
|
||||
* Preserves existing fields and merges in new values.
|
||||
*/
|
||||
export async function writeConfig(updates: Partial<GlobalConfig>): Promise<void> {
|
||||
const configPath = getConfigPath();
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Read existing config and merge
|
||||
const existing = await readConfig();
|
||||
const merged = { ...existing, ...updates };
|
||||
|
||||
// Deep merge for telemetry object
|
||||
if (updates.telemetry && existing.telemetry) {
|
||||
merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the telemetry config section.
|
||||
*/
|
||||
export async function getTelemetryConfig(): Promise<TelemetryConfig> {
|
||||
const config = await readConfig();
|
||||
return config.telemetry ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the telemetry config section.
|
||||
*/
|
||||
export async function updateTelemetryConfig(updates: Partial<TelemetryConfig>): Promise<void> {
|
||||
const existing = await getTelemetryConfig();
|
||||
await writeConfig({
|
||||
telemetry: { ...existing, ...updates },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Telemetry module for anonymous usage analytics.
|
||||
*
|
||||
* 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
|
||||
* - Auto-disabled in CI environments
|
||||
* - Anonymous ID is a random UUID with no relation to the user
|
||||
*/
|
||||
import { PostHog } from 'posthog-node';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
|
||||
|
||||
// PostHog API key - public key for client-side analytics
|
||||
// This is safe to embed as it only allows sending events, not reading data
|
||||
const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
|
||||
// Using reverse proxy to avoid ad blockers and keep traffic on our domain
|
||||
const POSTHOG_HOST = 'https://edge.openspec.dev';
|
||||
|
||||
let posthogClient: PostHog | null = null;
|
||||
let anonymousId: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if telemetry is enabled.
|
||||
*
|
||||
* Disabled when:
|
||||
* - OPENSPEC_TELEMETRY=0
|
||||
* - DO_NOT_TRACK=1
|
||||
* - CI=true (any CI environment)
|
||||
*/
|
||||
export function isTelemetryEnabled(): boolean {
|
||||
// Check explicit opt-out
|
||||
if (process.env.OPENSPEC_TELEMETRY === '0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Respect DO_NOT_TRACK standard
|
||||
if (process.env.DO_NOT_TRACK === '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-disable in CI environments
|
||||
if (process.env.CI === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the anonymous user ID.
|
||||
* Lazily generates a UUID on first call and persists it.
|
||||
*/
|
||||
export async function getOrCreateAnonymousId(): Promise<string> {
|
||||
// Return cached value if available
|
||||
if (anonymousId) {
|
||||
return anonymousId;
|
||||
}
|
||||
|
||||
// Try to load from config
|
||||
const config = await getTelemetryConfig();
|
||||
if (config.anonymousId) {
|
||||
anonymousId = config.anonymousId;
|
||||
return anonymousId;
|
||||
}
|
||||
|
||||
// Generate new UUID and persist
|
||||
anonymousId = randomUUID();
|
||||
await updateTelemetryConfig({ anonymousId });
|
||||
return anonymousId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PostHog client instance.
|
||||
* Creates it on first call with CLI-optimized settings.
|
||||
*/
|
||||
function getClient(): PostHog {
|
||||
if (!posthogClient) {
|
||||
posthogClient = new PostHog(POSTHOG_API_KEY, {
|
||||
host: POSTHOG_HOST,
|
||||
flushAt: 1, // Send immediately, don't batch
|
||||
flushInterval: 0, // No timer-based flushing
|
||||
});
|
||||
}
|
||||
return posthogClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a command execution.
|
||||
*
|
||||
* @param commandName - The command name (e.g., 'init', 'change:apply')
|
||||
* @param version - The OpenSpec version
|
||||
*/
|
||||
export async function trackCommand(commandName: string, version: string): Promise<void> {
|
||||
if (!isTelemetryEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = await getOrCreateAnonymousId();
|
||||
const client = getClient();
|
||||
|
||||
client.capture({
|
||||
distinctId: userId,
|
||||
event: 'command_executed',
|
||||
properties: {
|
||||
command: commandName,
|
||||
version: version,
|
||||
surface: 'cli',
|
||||
$ip: null, // Explicitly disable IP tracking
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Silent failure - telemetry should never break CLI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show first-run telemetry notice if not already seen.
|
||||
*/
|
||||
export async function maybeShowTelemetryNotice(): Promise<void> {
|
||||
if (!isTelemetryEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getTelemetryConfig();
|
||||
if (config.noticeSeen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Display notice
|
||||
console.log(
|
||||
'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0'
|
||||
);
|
||||
|
||||
// Mark as seen
|
||||
await updateTelemetryConfig({ noticeSeen: true });
|
||||
} catch {
|
||||
// Silent failure - telemetry should never break CLI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the PostHog client and flush pending events.
|
||||
* Call this before CLI exit.
|
||||
*/
|
||||
export async function shutdown(): Promise<void> {
|
||||
if (!posthogClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await posthogClient.shutdown();
|
||||
} catch {
|
||||
// Silent failure - telemetry should never break CLI exit
|
||||
} finally {
|
||||
posthogClient = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
readConfig,
|
||||
writeConfig,
|
||||
getTelemetryConfig,
|
||||
updateTelemetryConfig,
|
||||
} from '../../src/telemetry/config.js';
|
||||
|
||||
describe('telemetry/config', () => {
|
||||
let tempDir: string;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for tests
|
||||
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${Date.now()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Mock HOME to point to temp dir
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore HOME
|
||||
process.env.HOME = originalHome;
|
||||
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getConfigPath', () => {
|
||||
it('should return path to config.json in .config/openspec', () => {
|
||||
const result = getConfigPath();
|
||||
expect(result).toBe(path.join(tempDir, '.config', 'openspec', 'config.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('readConfig', () => {
|
||||
it('should return empty object when config file does not exist', async () => {
|
||||
const config = await readConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('should load valid config from file', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'test-id', noticeSeen: true }
|
||||
}));
|
||||
|
||||
const config = await readConfig();
|
||||
expect(config.telemetry).toEqual({ anonymousId: 'test-id', noticeSeen: true });
|
||||
});
|
||||
|
||||
it('should return empty object for invalid JSON', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, '{ invalid json }');
|
||||
|
||||
const config = await readConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeConfig', () => {
|
||||
it('should create directory if it does not exist', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
|
||||
await writeConfig({ telemetry: { noticeSeen: true } });
|
||||
|
||||
expect(fs.existsSync(configDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should write config to file', async () => {
|
||||
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
|
||||
|
||||
await writeConfig({ telemetry: { anonymousId: 'test-123' } });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.telemetry.anonymousId).toBe('test-123');
|
||||
});
|
||||
|
||||
it('should preserve existing fields when updating', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
// Create initial config with other fields
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
existingField: 'preserved',
|
||||
telemetry: { anonymousId: 'old-id' }
|
||||
}));
|
||||
|
||||
// Update telemetry
|
||||
await writeConfig({ telemetry: { noticeSeen: true } });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.existingField).toBe('preserved');
|
||||
expect(parsed.telemetry.noticeSeen).toBe(true);
|
||||
});
|
||||
|
||||
it('should deep merge telemetry fields', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
// Create initial config
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'existing-id' }
|
||||
}));
|
||||
|
||||
// Update with noticeSeen only
|
||||
await writeConfig({ telemetry: { noticeSeen: true } });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.telemetry.anonymousId).toBe('existing-id');
|
||||
expect(parsed.telemetry.noticeSeen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTelemetryConfig', () => {
|
||||
it('should return empty object when no config exists', async () => {
|
||||
const config = await getTelemetryConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('should return telemetry section from config', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'my-id', noticeSeen: false }
|
||||
}));
|
||||
|
||||
const config = await getTelemetryConfig();
|
||||
expect(config).toEqual({ anonymousId: 'my-id', noticeSeen: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTelemetryConfig', () => {
|
||||
it('should create telemetry config when none exists', async () => {
|
||||
await updateTelemetryConfig({ anonymousId: 'new-id' });
|
||||
|
||||
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.telemetry.anonymousId).toBe('new-id');
|
||||
});
|
||||
|
||||
it('should merge with existing telemetry config', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'existing-id' }
|
||||
}));
|
||||
|
||||
await updateTelemetryConfig({ noticeSeen: true });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.telemetry.anonymousId).toBe('existing-id');
|
||||
expect(parsed.telemetry.noticeSeen).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// Mock posthog-node before importing the module
|
||||
vi.mock('posthog-node', () => {
|
||||
return {
|
||||
PostHog: vi.fn().mockImplementation(() => ({
|
||||
capture: vi.fn(),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocking
|
||||
import { isTelemetryEnabled, maybeShowTelemetryNotice, shutdown, trackCommand } from '../../src/telemetry/index.js';
|
||||
import { PostHog } from 'posthog-node';
|
||||
|
||||
describe('telemetry/index', () => {
|
||||
let tempDir: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test using UUID
|
||||
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Save original env
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Mock HOME to point to temp dir
|
||||
process.env.HOME = tempDir;
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Spy on console.log for notice tests
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
process.env = originalEnv;
|
||||
|
||||
// Clean up temp directory
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
// Restore all mocks
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isTelemetryEnabled', () => {
|
||||
it('should return false when OPENSPEC_TELEMETRY=0', () => {
|
||||
process.env.OPENSPEC_TELEMETRY = '0';
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when DO_NOT_TRACK=1', () => {
|
||||
process.env.DO_NOT_TRACK = '1';
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CI=true', () => {
|
||||
process.env.CI = 'true';
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when no opt-out is set', () => {
|
||||
delete process.env.OPENSPEC_TELEMETRY;
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
expect(isTelemetryEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize OPENSPEC_TELEMETRY=0 over other settings', () => {
|
||||
process.env.OPENSPEC_TELEMETRY = '0';
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeShowTelemetryNotice', () => {
|
||||
it('should not show notice when telemetry is disabled', async () => {
|
||||
process.env.OPENSPEC_TELEMETRY = '0';
|
||||
|
||||
await maybeShowTelemetryNotice();
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackCommand', () => {
|
||||
it('should not track when telemetry is disabled', async () => {
|
||||
process.env.OPENSPEC_TELEMETRY = '0';
|
||||
|
||||
await trackCommand('test', '1.0.0');
|
||||
|
||||
expect(PostHog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should track when telemetry is enabled', async () => {
|
||||
delete process.env.OPENSPEC_TELEMETRY;
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
|
||||
await trackCommand('test', '1.0.0');
|
||||
|
||||
expect(PostHog).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('should not throw when no client exists', async () => {
|
||||
await expect(shutdown()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle shutdown errors silently', async () => {
|
||||
const mockPostHog = {
|
||||
capture: vi.fn(),
|
||||
shutdown: vi.fn().mockRejectedValue(new Error('Network error')),
|
||||
};
|
||||
(PostHog as any).mockImplementation(() => mockPostHog);
|
||||
|
||||
await expect(shutdown()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user