Skip to content

feat: telemetry opt-out command + first-run notice#169

Merged
nicknisi merged 6 commits into
mainfrom
nicknisi/telemetry-opt-out
Jun 2, 2026
Merged

feat: telemetry opt-out command + first-run notice#169
nicknisi merged 6 commits into
mainfrom
nicknisi/telemetry-opt-out

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented Jun 2, 2026

Summary

Gives users a first-class, reversible way to opt out of CLI telemetry (collection stays on by default) plus a one-time notice that it's happening. Closes the trust/norms gap left by the always-on telemetry added in #122 — until now the only control was the undocumented WORKOS_TELEMETRY=false env var.

What's included

  • workos telemetry opt-out / opt-in / status — reversible commands. status reports the effective state and its source (env var / saved preference / default), with human and JSON output.
  • Persisted preference at a plain ~/.workos/preferences.json (mirrors the non-secret device-id file pattern — no keyring, no insecure-fallback warning). Folded into the single Analytics.isEnabled() gate so opt-out suppresses all event types (session.start, command, crash).
  • Env precedenceWORKOS_TELEMETRY overrides the saved preference in both directions for that invocation. Replaced the import-time WORKOS_TELEMETRY_ENABLED const (which conflated "unset" with "true") with a call-time tri-state isTelemetryEnabled() resolver.
  • First-run notice — one-time, stderr-only box (human/interactive mode only; suppressed in --json/non-TTY/CI and when already opted out), backed by a persisted noticeShownAt timestamp. Marked shown only when actually displayed, so a human eventually sees it even if early runs are scripted.
  • telemetry is skip-listed so managing your preference never itself emits telemetry; registered in the help-json command tree.
  • debug integrationdebug state surfaces telemetry enabled/optedOut/source/noticeShown + the preferences path; debug reset clears preferences alongside config.
  • Box rendering fixrenderStderrBox was single-line-only and broke its border when a message exceeded the terminal width. Now wraps (ANSI-aware), fixing the notice on narrow terminals and benefiting the unclaimed-env warning/provision boxes too.

Testing

  • pnpm test — 2028 tests pass (new specs for the preferences store, the telemetry command, the first-run notice gates, the box wrapping, and debug integration).
  • Integration spec proves opt-out emits zero events and that env precedence holds both directions.
  • pnpm typecheck and pnpm build pass; changed files pass oxfmt --check.

Manual

workos telemetry status            # enabled, source: default
workos telemetry opt-out
workos telemetry status            # disabled, source: saved preference
WORKOS_TELEMETRY=true workos telemetry status   # enabled, source: env var

Notes

  • Two local-only files were intentionally left out of this PR: scripts/test-local-telemetry.sh (contains a hardcoded local-dev API key) and scripts/datadog-cli-dashboard.json. Flag if either should be tracked (the script needs the key removed first).

🤖 Generated with Claude Code

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 2, 2026

Greptile Summary

This PR adds first-class, reversible telemetry opt-out to the WorkOS CLI. It introduces a preferences.json store (~/.workos/preferences.json), three new workos telemetry subcommands (opt-out, opt-in, status), a one-time first-run notice on stderr, a fix to the box renderer for narrow terminals, and replaces the import-time WORKOS_TELEMETRY_ENABLED constant with a call-time isTelemetryEnabled() resolver that correctly interprets the env var as a tri-state override.

  • src/lib/preferences.ts — New store with an async prewarm (loadPreferences), a synchronous cached accessor (getPreferences), a read-modify-write save that preserves unrelated fields, and a clearPreferences used by debug reset.
  • src/lib/telemetry-notice.ts — First-run stderr notice with per-session and persistent guards; correctly sets shownThisSession only after a successful render and marks the disk only after render, so a write failure retries rather than silently suppressing.
  • src/utils/box.tsrenderStderrBox now wraps ANSI-aware on narrow terminals instead of letting the border break; the token regex treats each self-closing chalk span as an atomic unit.

Confidence Score: 5/5

Safe to merge — all changed paths are additive, non-breaking, and backed by comprehensive unit and integration tests.

The preferences store, notice gating, box renderer, and telemetry resolver are all correctly implemented with no observable defects. The most subtle invariants — render-then-persist ordering in the notice, truthy-empty-object sentinel after clearPreferences, and the tri-state env resolver — are each verified by dedicated tests. The shift from an import-time constant to a call-time isTelemetryEnabled() is backward-compatible and correctly prewarmed before any events fire.

No files require special attention.

Important Files Changed

Filename Overview
src/lib/preferences.ts New preferences store with async prewarm, synchronous cached fallback, read-modify-write save, and clearPreferences. Cache logic is correct: empty-object sentinel (truthy) is used after clearPreferences so subsequent reads skip disk. Deep merge of the telemetry sub-object correctly preserves unrelated fields like noticeShownAt when only optedOut changes.
src/lib/telemetry-notice.ts First-run notice gates (json mode, opted-out, already shown, per-session flag) are ordered correctly. shownThisSession and markNoticeShown are set only after a successful render, so a render or write failure retries on the next call within the session.
src/utils/box.ts wrapAnsiAware tokenizes colored spans as atomic units so chalk color doesn't bleed across wrap boundaries. renderStderrBox fast-path is unchanged; wrap-path correctly snugs the border to the longest wrapped line. Documented limitation on stacked SGR styles is acceptable given current callers use only single-layer chalk colors.
src/utils/analytics.ts isEnabled() now delegates to isTelemetryEnabled() (call-time resolver) instead of the import-time WORKOS_TELEMETRY_ENABLED constant. Since loadPreferences() is awaited in bin.ts before initForNonInstaller(), the cache is warm before any events fire.
src/commands/telemetry.ts New opt-out/opt-in/status commands. persistOptedOut wraps the write and surfaces a CliExit on failure so users see a clear error rather than a silent no-op. Status command correctly exposes the tri-state source via describeSource.
src/bin.ts loadPreferences() is correctly awaited before analytics.initForNonInstaller(). Telemetry middleware skips the notice for command === 'telemetry' and command === '' (bare help/version). The WORKOS_TELEMETRY_ENABLED import is cleanly removed.
src/bin-command-telemetry.integration.spec.ts New integration tests cover opted-out preference suppressing all events, env=true overriding an opt-out, and env=false suppressing events on a fresh install. WORKOS_TELEMETRY='' (empty string) correctly falls through to the preference in the tri-state resolver. pendingDir ENOENT is now handled with try/catch.
src/commands/debug.ts debug state now reports telemetry enabled/optedOut/source/noticeShown and the preferences path. debug reset clears preferences alongside config (clearPrefs = clearConf) — consistent with the 'preferences ride with config' design decision.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[bin.ts startup] --> B[await loadPreferences]
    B --> C[analytics.initForNonInstaller]
    C --> D[yargs middleware]
    D --> E{command === 'telemetry' or empty?}
    E -- yes --> F[skip notice]
    E -- no --> G[maybeShowTelemetryNotice]
    G --> H{isJsonMode?}
    H -- yes --> I[suppress]
    H -- no --> J{isTelemetryOptedOut?}
    J -- yes --> I
    J -- no --> K{isNoticeShown?}
    K -- yes --> I
    K -- no --> L[renderStderrBox]
    L --> M[shownThisSession = true]
    M --> N[markNoticeShown — persist noticeShownAt]

    O[workos telemetry opt-out] --> P[persistOptedOut true]
    P --> Q[savePreferences — read-modify-write]
    Q --> R[cached = merged]

    S[isTelemetryEnabled] --> T{envTelemetryOverride?}
    T -- true/false --> U[return env value]
    T -- undefined --> V{isTelemetryOptedOut?}
    V -- true --> W[return false]
    V -- false --> X[return true — default on]

    Y[analytics.isEnabled] --> S
Loading

Reviews (2): Last reviewed commit: "chore(telemetry): drop unused savePrefer..." | Re-trigger Greptile

Comment thread src/lib/preferences.ts Outdated
Comment thread src/lib/preferences.ts
Comment thread src/lib/telemetry-notice.ts Outdated
Comment thread src/utils/box.ts
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment thread src/lib/telemetry-notice.ts Outdated
nicknisi added 5 commits June 1, 2026 21:13
Add `workos telemetry opt-out / opt-in / status` backed by a plain
~/.workos/preferences.json store. The opt-out flag folds into the single
Analytics.isEnabled() gate, suppressing all telemetry events when set.

WORKOS_TELEMETRY now resolves at call time as a tri-state override: only
'true'/'false' override the saved preference (in both directions); any
other value falls through to the preference. Replaces the import-time
WORKOS_TELEMETRY_ENABLED const with isTelemetryEnabled().

- New preferences store (sync accessor + async prewarm, never-throws read
  path, write path surfaces failures so opt-out/opt-in report non-persist)
- telemetry command registered in bin.ts + help-json registry; added to
  SKIP_TELEMETRY_COMMANDS so managing the preference emits no telemetry
- Prewarm loadPreferences() before analytics.initForNonInstaller()
- Unit specs (store + handlers) and integration coverage proving opt-out
  emits zero events and env precedence holds in both directions
Add a one-time, stderr-only notice that tells users the WorkOS CLI
collects anonymous usage telemetry and how to opt out. Shown at most
once ever, only in interactive human mode, and never on the
machine-readable path.

- preferences.ts: add isNoticeShown()/markNoticeShown() (persists
  telemetry.noticeShownAt, read-modify-write preserves optedOut)
- telemetry-notice.ts: maybeShowTelemetryNotice() with per-session
  guard, json-mode/opted-out/already-shown gates, never-throws; marks
  shown ONLY after actually rendering so non-human first runs never
  consume the one-time display
- bin.ts: wire into middleware, skipping the telemetry command and the
  empty/root command (bare --help/--version)
- unit specs for every gate and the mark-only-on-display rule
renderStderrBox drew a single-line border sized to the message's visible
length, so any message wider than the terminal soft-wrapped and broke the
border. The first-run telemetry notice (~90 chars) hit this on normal and
narrow terminals.

Make the box width-aware: an ANSI-aware word wrap (wrapAnsiAware) tokenizes
into atomic self-closed color spans plus plain words and packs them by visible
width, so color never bleeds onto the border. A backward-compatible single-line
fast path keeps wide-terminal output byte-for-byte identical; only overflow
triggers the multi-line box, snugged to the longest wrapped line. Fixes all
three renderStderrBox callers (telemetry notice, unclaimed-env warning/provision).

Adds box.spec.ts covering wrapping, atomic color spans, visible-width
measurement, and border alignment at narrow widths.
…on reset

debug state now reports telemetry enabled/optedOut/source/noticeShown and the
preferences file path, so users and support can see the effective state and
where it lives. debug reset clears ~/.workos/preferences.json alongside config
(non-secret local CLI state rides with the config target), returning telemetry
to its fresh-install state. Adds clearPreferences() to the preferences store.
- telemetry-notice: render the opt-out command via formatWorkOSCommand so npx
  users see the working invocation (e.g. 'npx workos@latest telemetry opt-out')
  instead of a hardcoded 'workos ...'; add a regression test.
- telemetry-notice: set the per-session guard and persist 'shown' only AFTER a
  successful render, so a render failure lets a later command retry instead of
  silently suppressing the notice for the rest of the session.
- preferences: getTelemetrySource now returns 'default' (not 'preference') when
  optedOut is explicitly false, matching isTelemetryEnabled()'s precedence — an
  opted-in state is indistinguishable from a fresh install.
- preferences: drop the unused 'mkdir' import.
- box: document the single-level-SGR assumption and the long atomic-token
  overflow caveat (e.g. the npx command form on very narrow terminals).
@nicknisi
Copy link
Copy Markdown
Member Author

nicknisi commented Jun 2, 2026

Addressed review feedback in f969135:

  • telemetry-notice (devin): opt-out command now rendered via formatWorkOSCommand('telemetry opt-out'), so npx users get the working invocation instead of a hardcoded workos .... Added a regression test.
  • telemetry-notice (greptile): shownThisSession/markNoticeShown now run only after a successful render, so a render failure lets a later command retry instead of suppressing the notice for the rest of the session.
  • preferences getTelemetrySource (greptile): returns default (not preference) when optedOut === false, matching isTelemetryEnabled()'s precedence — an opted-in state is indistinguishable from a fresh install. Updated the corresponding test.
  • preferences (greptile): removed the unused mkdir import.
  • box wrapping (greptile): documented the single-level-SGR assumption (stacked/adjacent spans not guaranteed atomic) and the long atomic-token overflow caveat (e.g. the npx command form on very narrow terminals). Did not change the regex — a multi-layer pattern would mis-group adjacent spans and regress color for no current caller.

pnpm test (2029), typecheck, build, and oxfmt --check all pass.

@nicknisi nicknisi force-pushed the nicknisi/telemetry-opt-out branch from f969135 to 9cf0ce2 Compare June 2, 2026 02:13
@nicknisi nicknisi merged commit cb16ebb into main Jun 2, 2026
6 checks passed
@nicknisi nicknisi deleted the nicknisi/telemetry-opt-out branch June 2, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant