Skip to content

feat(cli): anonymous opt-out usage analytics + stash telemetry#644

Merged
coderdan merged 10 commits into
mainfrom
feat/cli-anonymous-telemetry
Jul 15, 2026
Merged

feat(cli): anonymous opt-out usage analytics + stash telemetry#644
coderdan merged 10 commits into
mainfrom
feat/cli-anonymous-telemetry

Conversation

@coderdan

@coderdan coderdan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Adds anonymous, opt-out usage analytics to the stash CLI, a stash telemetry [status|enable|disable] command to manage it, and the Cloudflare reverse proxy the CLI reports through. Stacked on the EQL v3 RC (feat/eql-v3-text-search-schema, #535).

What it collects

Coarse events only: command name, CLI version, OS/arch, success/failure, duration, and the error class on failure. A property allowlist enforced at the emitter boundary (sanitize in telemetry/index.ts) makes it structurally impossible for plaintext, schema, table/column names, connection strings, or argument values to leave the machine — this is the load-bearing guarantee for a zero-knowledge product, and it has direct tests.

Behaviour

  • Opt-out by default, Homebrew-style: a one-time notice prints to stderr on first run, and nothing is sent on the run that shows it.
  • Four opt-out gates (any one wins; env overrides beat the saved preference): DO_NOT_TRACK=1 (cross-tool standard), STASH_TELEMETRY_DISABLED=1, CI auto-detect, and persisted stash telemetry disable.
  • Anonymous: a random salted machine ID; PostHog person profiles disabled.
  • Never blocks the CLI: lazy client, posthog-node events-only, flush bounded by a 1.5s timeout, all failures swallowed.
  • State lives in ~/.cipherstash/telemetry.json — a non-secret file distinct from the auth credentials.

Proxy (infra/telemetry-proxy/)

A Cloudflare Worker forwards telemetry.cipherstash.com → PostHog, with the target host in a POSTHOG_HOST variable. This makes the deferred US→EU PostHog migration a var-change + redeploy with no CLI re-release — every installed stash follows the proxy. It's an allowlisted forwarder, not an open proxy.

Ships dormant

The embedded PostHog key is a placeholder, so telemetry is fully inert (resolveStatusunconfigured, no banner, no sends) until the real public write-only key is embedded at release. Safe to merge before the infra is live.

Tests

423 CLI tests pass, including 16 new ones covering gate precedence, CI detection, the allowlist (asserts table/databaseUrl/plaintext etc. are dropped), and state roundtrip/corruption recovery.

Follow-ups (outside this PR)

  • Deploy the Worker (infra/telemetry-proxy/README.md) and embed the real PostHog key at release.
  • Publish the cipherstash.com/docs/reference/cli page the first-run notice links to.

Notes

  • posthog-node was already a vetted dependency; it's newly used here.
  • Updates skills/stash-cli/SKILL.md (new command + Telemetry section) — hence the stash changeset.

https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

Summary by CodeRabbit

  • New Features

    • Added anonymous, opt-out usage analytics for CLI commands.
    • Added stash telemetry status|enable|disable for managing analytics preferences.
    • Added first-run disclosure and automatic opt-out in CI environments.
    • Added environment-variable controls and persisted settings for disabling telemetry.
    • Telemetry excludes sensitive command and data details and does not block CLI execution.
  • Documentation

    • Documented telemetry behavior, collected data, exclusions, and opt-out options.
  • Tests

    • Added coverage for telemetry state, privacy filtering, lifecycle behavior, and command classification.

Add anonymous, opt-out usage analytics to the stash CLI, plus a
`stash telemetry [status|enable|disable]` command to manage it.

- Coarse events only (command, version, OS/arch, success, duration);
  a property allowlist at the emitter boundary makes it impossible for
  plaintext, schema, table/column names, connection strings, or argument
  values to leave the machine.
- Opt-out by default, Homebrew-style: nothing is sent until the one-time
  first-run notice has been shown once. Four opt-out gates in precedence
  order: DO_NOT_TRACK, STASH_TELEMETRY_DISABLED, CI auto-detect, and the
  persisted `stash telemetry disable` flag in ~/.cipherstash/telemetry.json.
- Events go through a first-party Cloudflare proxy (infra/telemetry-proxy),
  so a future US->EU PostHog move is a proxy-target change with no CLI
  re-release. posthog-node, events-only (no person profiles), lazy client,
  flush bounded by a timeout so it never blocks or slows the CLI.
- Ships dormant: no events are sent until a real PostHog key is embedded
  at release (STASH_POSTHOG_KEY overrides for testing).

Updates the stash-cli skill and adds a changeset.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan
coderdan requested a review from a team as a code owner July 14, 2026 03:50
@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fcc3047

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
stash Minor
@cipherstash/wizard Patch
@cipherstash/e2e Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds anonymous, opt-out telemetry to the stash CLI, including persisted settings, environment and CI gating, sanitized command events, first-run disclosure, lifecycle flushing, CLI controls, caller classification, tests, documentation, and release-time PostHog key embedding.

Changes

CLI telemetry

Layer / File(s) Summary
CI and caller classification
packages/cli/src/config/tty.ts, packages/cli/src/config/__tests__/tty.test.ts, packages/cli/tests/helpers/pty.ts
Adds broad CI detection, caller classification, and CI-isolated PTY environments.
Persisted telemetry state
packages/cli/src/telemetry/state.ts, packages/cli/src/telemetry/__tests__/state.test.ts
Persists normalized anonymous telemetry state under ~/.cipherstash/telemetry.json with private permissions and recovery from invalid data.
Telemetry gating and event lifecycle
packages/cli/src/telemetry/index.ts, packages/cli/src/telemetry/classify-command.ts, packages/cli/src/telemetry/__tests__/*
Adds project-key gating, opt-out precedence, allowlisted properties, safe command classification, first-run notices, event capture, and bounded shutdown flushing.
Telemetry release configuration
packages/cli/tsup.config.ts, turbo.json, .github/workflows/release.yml
Embeds the release PostHog key into both CLI bundles and propagates it through build and publishing configuration.
CLI command and documentation integration
packages/cli/src/bin/main.ts, packages/cli/src/commands/..., packages/cli/src/cli/registry.ts, packages/cli/src/messages.ts, skills/stash-cli/SKILL.md, .changeset/cli-anonymous-telemetry.md
Adds telemetry command routing, lifecycle wrapping, help and messages, documentation, and release notes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StashCLI
  participant TelemetryModule
  participant PostHogClient
  participant TelemetryProxy
  StashCLI->>TelemetryModule: initialize and resolve status
  StashCLI->>TelemetryModule: track command_invoked
  TelemetryModule->>PostHogClient: capture sanitized event
  PostHogClient->>TelemetryProxy: send event
  StashCLI->>TelemetryModule: shutdown and flush
Loading

Possibly related PRs

Suggested reviewers: auxesis, calvinbrewer

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: anonymous opt-out telemetry plus the new stash telemetry command.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cli-anonymous-telemetry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an anonymous, opt-out telemetry system to the stash CLI (with a persisted machine-level state file), introduces a stash telemetry [status|enable|disable] command to manage the saved preference, and includes a Cloudflare Worker reverse-proxy so the CLI reports to a first-party endpoint that can be retargeted without re-releasing the CLI.

Changes:

  • Implement CLI telemetry capture with opt-out gating, a one-time first-run notice, property allowlisting, and bounded shutdown flushing.
  • Add stash telemetry subcommand + registry/docs wiring.
  • Add infra/telemetry-proxy/ Cloudflare Worker + deploy docs, plus a changeset for the CLI change.

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
skills/stash-cli/SKILL.md Documents the new telemetry command and opt-out mechanisms in the published CLI skill.
packages/cli/src/telemetry/state.ts Implements persisted telemetry state (~/.cipherstash/telemetry.json) with corruption tolerance and 0600 permissions.
packages/cli/src/telemetry/index.ts Telemetry core: gating, allowlist sanitization, PostHog client creation, first-run notice, capture + shutdown flush.
packages/cli/src/telemetry/tests/telemetry.test.ts Adds tests for gate precedence and property allowlist behavior.
packages/cli/src/telemetry/tests/state.test.ts Adds tests for telemetry state persistence and corruption recovery.
packages/cli/src/messages.ts Adds the first-run telemetry notice and stash telemetry UX strings.
packages/cli/src/commands/telemetry/index.ts Implements `stash telemetry [status
packages/cli/src/commands/index.ts Exports the new telemetry command.
packages/cli/src/cli/registry.ts Registers telemetry in the CLI command registry/help surface.
packages/cli/src/bin/main.ts Wires telemetry init/notice/tracking/shutdown into the main CLI dispatch path.
infra/telemetry-proxy/wrangler.toml Adds Worker config for telemetry.cipherstash.com and POSTHOG_HOST target var.
infra/telemetry-proxy/worker.js Implements allowlisted-path reverse proxy to PostHog ingestion endpoints.
infra/telemetry-proxy/README.md Adds deploy + verification instructions for the proxy Worker.
.changeset/cli-anonymous-telemetry.md Records the CLI user-facing feature addition for release notes/versioning.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/cli/src/telemetry/index.ts
Comment thread packages/cli/src/telemetry/index.ts Outdated
Comment thread packages/cli/src/messages.ts Outdated
Comment thread packages/cli/src/telemetry/__tests__/state.test.ts
Comment thread infra/telemetry-proxy/worker.js Outdated
Review findings:
- shutdownTelemetry: clear the flush-timeout timer so a fast flush no longer
  leaves a dangling setTimeout that hangs the process for the full timeout; also
  set fetchRetryCount:0 so an unreachable endpoint fails fast instead of
  retrying with backoff (which kept internal timers alive and hung the CLI for
  seconds). Enabled commands now exit in ~140ms even when the endpoint is down.
- First-run notice: persist noticeShownAt ONLY when the notice was actually
  displayed (stderr is a TTY), so a non-interactive first run no longer consumes
  the freebie and starts sending without the user ever seeing the disclosure.
- Broaden DO_NOT_TRACK / STASH_TELEMETRY_DISABLED to opt out on any non-empty
  value except 0/false (matches the DO_NOT_TRACK convention).
- Reuse the shared isCiEnv() from config/tty.ts instead of a second, divergent
  CI detector; widen isCiEnv() with provider markers (GITHUB_ACTIONS, GITLAB_CI,
  Azure TF_BUILD, Jenkins, TeamCity, …) so telemetry never fires in CI even when
  a provider doesn't set CI=true. Export CI_ENV_VARS and make the region /
  database-url tests hermetic against those ambient vars.
- Drop the dead resolveStatus() re-resolve after writing the notice.

Copilot:
- Sanitize the combined event+base props so the allowlist is the single enforced
  boundary (a future baseProps field can't bypass it).
- Make the first-run opt-out hint runner-aware (npx stash …) so it's actionable
  before stash is on PATH.
- Restore process.env.HOME in the state test so it can't leak across test files.
- Worker returns 500 when POSTHOG_HOST is unset instead of forwarding to an
  invalid host.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan

Copy link
Copy Markdown
Contributor Author

Addressed the review + Copilot feedback in ad77675.

Copilot comments

  • Notice persisted without display (index.ts:212): noticeShownAt is now written only when the notice is actually shown (stderr is a TTY), so a non-interactive first run no longer consumes the freebie and starts sending without disclosure.
  • Allowlist boundary (index.ts:190): now sanitize the combined event + base props, so the allowlist is the single enforced boundary and a future baseProps() field can't bypass it.
  • npx vs stash on PATH (messages.ts): the opt-out hint is now runner-aware (npx stash telemetry disable) and also names DO_NOT_TRACK=1, so it's actionable before stash is on PATH.
  • HOME not restored (state.test.ts): the test now snapshots and restores process.env.HOME in afterEach.
  • Worker POSTHOG_HOST unset (worker.js): returns a 500 instead of forwarding to an invalid host.

Review findings also fixed

  • The flush timer is now cleared (and fetchRetryCount: 0 set) so an enabled command exits in ~140ms even against an unreachable endpoint, instead of hanging.
  • DO_NOT_TRACK / STASH_TELEMETRY_DISABLED now opt out on any non-empty value except 0/false.
  • CI detection is unified on the shared isCiEnv() (config/tty.ts), widened with provider markers (GitHub, GitLab, Azure TF_BUILD, Jenkins, TeamCity, …) so telemetry never fires in CI even when a provider doesn't set CI=true.

425 tests pass; Biome clean.

Base automatically changed from feat/eql-v3-text-search-schema to main July 14, 2026 04:31
Widening isCiEnv() with provider markers (GITHUB_ACTIONS, GITLAB_CI, …) made the
pty e2e harness leak this repo's own CI env into the spawned CLI: the harness
defaults CI=true and interactive tests override with `CI: ''`, but that no longer
fully de-CIs the environment once GITHUB_ACTIONS=true is also present, so
`auth login` skipped the region prompt and the test timed out.

Strip every CI signal (CI_ENV_VARS) from the child env before applying the
harness's CI default and per-test overrides, so CI detection is fully controlled
by the harness — matching how the unit tests were made hermetic. Verified the
full e2e suite passes with GITHUB_ACTIONS=true CI=true exported.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/cli/src/bin/main.ts (1)

537-540: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid process.exit() in these unknown-command branches — it short-circuits run()’s finally, so the telemetry event/flush never runs on typo/error exits. Throw instead, or flush before exiting, in packages/cli/src/bin/main.ts#L537-L540 and packages/cli/src/commands/telemetry/index.ts#L60-L63.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/bin/main.ts` around lines 537 - 540, Replace the direct
process.exit() calls in the unknown-command branches of
packages/cli/src/bin/main.ts lines 537-540 and
packages/cli/src/commands/telemetry/index.ts lines 60-63 with thrown errors so
run() reaches its finally block and telemetry flushes; preserve the existing
error message and help output.
🧹 Nitpick comments (3)
packages/cli/src/__tests__/database-url.test.ts (1)

43-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract duplicate test helpers to a shared module.

These test files contain identical logic for managing provider CI environment variables. Extracting this setup into a shared helper (e.g., in packages/cli/tests/helpers/) will keep the test suite DRY and easier to maintain.

  • packages/cli/src/__tests__/database-url.test.ts#L43-L66: extract the duplicated savedProviderCi, neutralizeProviderCi, and restoreProviderCi logic into a shared test helper.
  • packages/cli/src/commands/auth/__tests__/region.test.ts#L33-L56: replace this duplicated logic with an import from the new shared test helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/__tests__/database-url.test.ts` around lines 43 - 66,
Extract the duplicated savedProviderCi, neutralizeProviderCi, and
restoreProviderCi logic into a shared test helper under
packages/cli/tests/helpers/, preserving exact environment restoration and CI
handling. In packages/cli/src/__tests__/database-url.test.ts:43-66, replace the
local state and functions with imports from the helper; apply the same
replacement in packages/cli/src/commands/auth/__tests__/region.test.ts:33-56.
packages/cli/src/telemetry/__tests__/telemetry.test.ts (1)

1-169: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding lifecycle coverage for trackCommand/maybeShowFirstRunNotice/shutdownTelemetry.

This suite thoroughly covers resolveStatus/sanitize (both pure), but the side-effecting lifecycle functions that depend on module-level state/status/firstRun are untested here. Since those variables are captured once at import time, exercising them would need vi.resetModules() + dynamic re-import per test — worth doing given they encode the "freebie first run" and "no-op when disabled" guarantees the PR relies on.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/telemetry/__tests__/telemetry.test.ts` around lines 1 - 169,
Extend the telemetry test suite with lifecycle coverage for trackCommand,
maybeShowFirstRunNotice, and shutdownTelemetry using vi.resetModules() and
dynamic imports per test so module-level state, status, and firstRun are
reinitialized. Verify the enabled first-run/freebie behavior and that disabled
telemetry paths are no-ops, reusing the existing environment setup and mocks
where needed.
packages/cli/src/commands/telemetry/index.ts (1)

10-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize these status strings in messages.ts for consistency.

enabled/disabled/unknownSubcommand already pull from messages.telemetry, but reasonText's per-reason strings and printStatus's info lines are inlined here instead. If any E2E test ever asserts on this copy, it'll need to track two places instead of one.

As per path instructions: "Keep assertion-stable user-facing strings in src/messages.ts and update the constants there when E2E tests assert on copy such as cancellation text, 'Unknown auth command', or the db migrate stub warning."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/telemetry/index.ts` around lines 10 - 38, Move the
user-facing telemetry status and reason strings used by reasonText and
printStatus into the existing telemetry section of messages.ts, alongside
enabled, disabled, and unknownSubcommand. Update these functions to reference
the centralized message constants while preserving the current wording and
status-specific behavior.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@infra/telemetry-proxy/worker.js`:
- Around line 48-49: Update the forwarded request construction in the proxy
handler to remove IP-revealing headers, including CF-Connecting-IP and
X-Forwarded-For, after copying the incoming request headers and before
forwarding to PostHog. Preserve the existing Host header assignment while
ensuring these anonymity-sensitive headers cannot reach the target.

---

Outside diff comments:
In `@packages/cli/src/bin/main.ts`:
- Around line 537-540: Replace the direct process.exit() calls in the
unknown-command branches of packages/cli/src/bin/main.ts lines 537-540 and
packages/cli/src/commands/telemetry/index.ts lines 60-63 with thrown errors so
run() reaches its finally block and telemetry flushes; preserve the existing
error message and help output.

---

Nitpick comments:
In `@packages/cli/src/__tests__/database-url.test.ts`:
- Around line 43-66: Extract the duplicated savedProviderCi,
neutralizeProviderCi, and restoreProviderCi logic into a shared test helper
under packages/cli/tests/helpers/, preserving exact environment restoration and
CI handling. In packages/cli/src/__tests__/database-url.test.ts:43-66, replace
the local state and functions with imports from the helper; apply the same
replacement in packages/cli/src/commands/auth/__tests__/region.test.ts:33-56.

In `@packages/cli/src/commands/telemetry/index.ts`:
- Around line 10-38: Move the user-facing telemetry status and reason strings
used by reasonText and printStatus into the existing telemetry section of
messages.ts, alongside enabled, disabled, and unknownSubcommand. Update these
functions to reference the centralized message constants while preserving the
current wording and status-specific behavior.

In `@packages/cli/src/telemetry/__tests__/telemetry.test.ts`:
- Around line 1-169: Extend the telemetry test suite with lifecycle coverage for
trackCommand, maybeShowFirstRunNotice, and shutdownTelemetry using
vi.resetModules() and dynamic imports per test so module-level state, status,
and firstRun are reinitialized. Verify the enabled first-run/freebie behavior
and that disabled telemetry paths are no-ops, reusing the existing environment
setup and mocks where needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 97e29ac0-3f73-4457-b803-3e488d1cd2f0

📥 Commits

Reviewing files that changed from the base of the PR and between 7752cae and 9048fc6.

📒 Files selected for processing (18)
  • .changeset/cli-anonymous-telemetry.md
  • infra/telemetry-proxy/README.md
  • infra/telemetry-proxy/worker.js
  • infra/telemetry-proxy/wrangler.toml
  • packages/cli/src/__tests__/database-url.test.ts
  • packages/cli/src/bin/main.ts
  • packages/cli/src/cli/registry.ts
  • packages/cli/src/commands/auth/__tests__/region.test.ts
  • packages/cli/src/commands/index.ts
  • packages/cli/src/commands/telemetry/index.ts
  • packages/cli/src/config/tty.ts
  • packages/cli/src/messages.ts
  • packages/cli/src/telemetry/__tests__/state.test.ts
  • packages/cli/src/telemetry/__tests__/telemetry.test.ts
  • packages/cli/src/telemetry/index.ts
  • packages/cli/src/telemetry/state.ts
  • packages/cli/tests/helpers/pty.ts
  • skills/stash-cli/SKILL.md

Comment thread infra/telemetry-proxy/worker.js Outdated
coderdan added 2 commits July 15, 2026 15:29
Symmetric with STASH_POSTHOG_KEY: the host now defaults to the Cloudflare proxy
(telemetry.cipherstash.com) but can be overridden for testing against a real
PostHog ingestion endpoint before the proxy is deployed, or for self-hosting.
Verified the CLI POSTs the event batch to the configured host.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Add a coarse, fixed-enum `caller` property to command telemetry so we can
gauge how much stash usage is agent-driven — a lot of it is our own skills
being run by a customer's coding agent.

resolveCaller() recognises Claude Code (CLAUDECODE / CLAUDE_CODE_ENTRYPOINT),
Cursor (CURSOR_TRACE_ID), and Codex (CODEX_SANDBOX) by the env var each sets,
falls back to `editor` for a VS Code-family terminal (TERM_PROGRAM=vscode),
then to `interactive`/`non-interactive` by stdin TTY. Only the fixed label is
emitted, never the raw env value — some carry session/trace ids — so the
anonymity contract holds. `caller` is added to the emitter allowlist.

Updates the stash-cli skill disclosure and the changeset field list to match.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
skills/stash-cli/SKILL.md (1)

160-163: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Avoid unpinned npx stash commands in the new workflow instructions.

These commands can download the mutable latest package when no local installation exists. Use the project-local binary after initialization or pin the exact CLI version for one-shot invocations, especially for privacy-sensitive opt-out controls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/stash-cli/SKILL.md` around lines 160 - 163, Update the telemetry
workflow instructions around the npx stash commands to avoid unpinned
invocations: use the project-local CLI binary after initialization, or specify
an exact CLI version for one-shot commands. Apply this consistently to telemetry
status, enable, and disable examples, preserving the existing opt-out behavior.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@skills/stash-cli/SKILL.md`:
- Line 150: Update the first-run notice wording in the documentation to specify
that it is shown and persisted only on the first interactive TTY run, rather
than on the first run generally.
- Around line 145-150: Update the telemetry disclosure in the CLI analytics
description to explicitly mention the anonymous salted machine identifier. State
that it is used only to aggregate anonymous events, while preserving the
existing list of excluded sensitive data and first-run notice behavior.

---

Outside diff comments:
In `@skills/stash-cli/SKILL.md`:
- Around line 160-163: Update the telemetry workflow instructions around the npx
stash commands to avoid unpinned invocations: use the project-local CLI binary
after initialization, or specify an exact CLI version for one-shot commands.
Apply this consistently to telemetry status, enable, and disable examples,
preserving the existing opt-out behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 92ab9e58-21ed-44b1-88a5-6921349560fd

📥 Commits

Reviewing files that changed from the base of the PR and between 92bedb9 and ed08f4c.

📒 Files selected for processing (6)
  • .changeset/cli-anonymous-telemetry.md
  • packages/cli/src/config/__tests__/tty.test.ts
  • packages/cli/src/config/tty.ts
  • packages/cli/src/telemetry/__tests__/telemetry.test.ts
  • packages/cli/src/telemetry/index.ts
  • skills/stash-cli/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • .changeset/cli-anonymous-telemetry.md
  • packages/cli/src/config/tty.ts
  • packages/cli/src/telemetry/tests/telemetry.test.ts
  • packages/cli/src/telemetry/index.ts

Comment thread skills/stash-cli/SKILL.md
Comment thread skills/stash-cli/SKILL.md
coderdan added 3 commits July 15, 2026 16:18
…repo

The Cloudflare Worker that terminates telemetry.cipherstash.com is deployed
platform infrastructure, not part of the published stash package, so it belongs
with the rest of our infra in cipherstash-suite (infra/telemetry-proxy) rather
than shipping in this repo. No CLI behaviour changes: the CLI still targets
https://telemetry.cipherstash.com and stays dormant until a key is embedded.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Wire up the injection the placeholder always promised. tsup's esbuild `define`
replaces the `__STASH_POSTHOG_KEY__` identifier with the key from the
STASH_POSTHOG_KEY env var; release.yml feeds it from a public repo *variable*
(vars.STASH_POSTHOG_KEY), so ONLY the official release build embeds a key. Dev,
fork, and CI builds bake in an empty string and ship telemetry-dormant. Applied
to both bundles; turbo.json lists the var on `build` so the cache key tracks it.

Also fixes a latent bug the injection would have exposed: resolveStatus compared
the resolved key against EMBEDDED_KEY, which once injected holds the real key —
so an embedded build with no env override would have read as `unconfigured`.
It now compares against a dedicated PLACEHOLDER_KEY sentinel (the un-rewritten
string literal), so an injected build is correctly enabled. New tests cover the
placeholder-passthrough (dormant) and injected-key (enabled) paths.

The go-live switch is setting the repo variable; until then every release stays
dormant. Verified both ways: dormant build reports "not active in this build",
injected build reports "Telemetry is enabled" with no runtime env override.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Ten findings from the high-effort review, all fixed:

1. subcommand value leak (privacy): trackCommand emitted argv[1] verbatim, so
   `stash wizard "<prompt>"` sent the natural-language prompt (table/column
   names) to PostHog. New classify-command.ts coerces command/subcommand to the
   registry's known verbs before emit; anything else becomes `<other>`.
2. non-TTY never activated: the first-run notice only persisted noticeShownAt on
   a TTY, so agents/piped callers stayed firstRun forever and never sent — the
   exact population the `caller` dimension measures. Now the disclosure is shown
   (to stderr) and persisted on every first run, TTY or not.
3. process.exit bypassed the tracking finally: commands exiting via process.exit
   emitted nothing and never flushed. run() now intercepts process.exit with an
   ExitSignal sentinel that unwinds through the finally (track + flush) before
   the real exit, honouring the exit code. Degrades gracefully if a command
   catch swallows it.
4. empty-string key enabled telemetry: STASH_POSTHOG_KEY='' slipped past the
   nullish fallback; projectKey() now trims and treats empty as unset.
5. first-run notice could show twice: maybeShowFirstRunNotice didn't reassign the
   state cache, so a same-run setTelemetryDisabled clobbered noticeShownAt. Fixed.
6. isCiEnv widening leaked into prompt gating: reverted the shared isCiEnv to its
   narrow (CI-only) behaviour used by region/database-url; telemetry uses a new
   isCiEnvBroad. Reverted the now-unnecessary test neutralizations.
7. eager cost on every invocation: posthog-node is now a dynamic import inside
   getClient (loaded only when an event is actually sent), and the state file
   read is lazy — both off the --version/--help fast paths.
8. shutdown could linger: pass requestTimeout to PostHog so a black-holed
   endpoint can't keep the socket alive past the flush window.
9. dormant tests depended on ambient STASH_POSTHOG_KEY: clearGateEnv now stubs it.
10. lifecycle path was untested: new telemetry-lifecycle.test.ts covers the
    emitter/flush contract (no-op when disabled/firstRun, swallows throws, safe
    double-shutdown, non-TTY notice persistence, bounded flush on a hung endpoint)
    and classify-command.test.ts covers the value allowlist.

Verified end-to-end against a local capture: a process.exit command is now
tracked with its exit code preserved, and a wizard prompt containing a
table.column is scrubbed to `<other>` (0 leakage). 453 unit tests pass.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/telemetry/__tests__/telemetry-lifecycle.test.ts`:
- Around line 1-4: Add the `dotenv/config` import at the top of the telemetry
lifecycle test file before the existing imports, preserving the current
`CI_ENV_VARS` and `TelemetryState` imports.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ee1d85ca-7e06-4e8c-9821-ced9514a2439

📥 Commits

Reviewing files that changed from the base of the PR and between 1b6b3e4 and 4e26a6b.

📒 Files selected for processing (7)
  • packages/cli/src/bin/main.ts
  • packages/cli/src/config/tty.ts
  • packages/cli/src/telemetry/__tests__/classify-command.test.ts
  • packages/cli/src/telemetry/__tests__/telemetry-lifecycle.test.ts
  • packages/cli/src/telemetry/__tests__/telemetry.test.ts
  • packages/cli/src/telemetry/classify-command.ts
  • packages/cli/src/telemetry/index.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/cli/src/bin/main.ts
  • packages/cli/src/telemetry/tests/telemetry.test.ts
  • packages/cli/src/telemetry/index.ts

Comment thread packages/cli/src/telemetry/__tests__/telemetry-lifecycle.test.ts
coderdan added 2 commits July 15, 2026 18:24
… harden telemetry per review round 2

The round-1 fix intercepted process.exit globally with a thrown signal. The
second review pass proved that unsafe: @clack/core calls process.exit(0) from a
keypress handler (Ctrl+C during any spinner became an uncaughtException crash),
and five broad catches (init's install-eql, plan, status, impl, the jiti config
loader) swallowed the signal — init continued past an explicit cancel, impl
skipped the cutover deploy-gate, and commands that recovered by design exited
with a stale latched code.

New design: no global interception. First-party sites verified to unwind
cleanly to run() throw `CliExit(code)` (new cli/exit.ts) — main.ts's own
unknown-command/subcommand defaults, requireValue, requireStack, the telemetry
command, and the outermost CancelledError handlers of init/plan/impl (cancels
are now tracked). Deep process.exit sites revert to pre-branch behaviour:
immediate, uninterceptable, untracked — documented as a known measurement gap
in the telemetry module doc and packages/cli/AGENTS.md.

Also fixed from the review:
- shutdownTelemetry: the capture enqueue (containing the dynamic posthog-node
  import) now sits INSIDE the Promise.race, so the 1500ms bound covers
  everything that can stall; the flush promise carries its own rejection
  handler so a timeout win can't leave an unhandledRejection.
- First-run notice persists BEFORE printing: an unwritable HOME no longer
  prints the notice on every run forever — no persist, no print, no telemetry.
- pty e2e harness sets STASH_TELEMETRY_DISABLED=1 so an ambient
  STASH_POSTHOG_KEY can't make tests send real events against the developer's
  real ~/.cipherstash.
- errorType is now a closed vocabulary (classifyErrorType): user-defined error
  class names from jiti-loaded config code collapse to `<other>`.
- Wizard analytics aligned with the CLI's privacy contract: honors
  DO_NOT_TRACK / STASH_TELEMETRY_DISABLED / CI, random per-session id instead
  of sha256(username@hostname), geoip off, and error events carry fixed
  labels / class names instead of raw messages.
- Docs de-staled: packages/cli/AGENTS.md telemetry section rewritten (it said
  the CLI had no telemetry), CI_ENV_VARS/resolveStatus/pty.ts comments now
  describe the isCiEnv (narrow, prompts) vs isCiEnvBroad (telemetry) split,
  and the changeset names the value-coercion boundary.
- Tests: narrow-vs-broad isCiEnv contract pinned; classifyErrorType covered;
  notice write-failure covered; stderr.isTTY save/restore no longer creates a
  read-only own property; smoke e2e covers `telemetry status` and the
  bogus-subcommand CliExit path.

Verified: 460 unit + 57 pty e2e tests pass; local capture shows unknown
command and telemetry bogus-sub tracked with correct exit codes, wizard
free-text still scrubbed to `<other>` (0 leakage), and a read-only-HOME first
run prints nothing.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
CodeRabbit flagged that the SKILL.md disclosure lists event fields but never
mentions the distinct_id. Add it — with accurate wording: it is a locally
generated random UUID stored in ~/.cipherstash/telemetry.json, used only to
de-duplicate events in aggregate, and NOT a salted/derived machine id (the
review's suggested phrasing described a mechanism we deliberately don't use).
Mirrored in the changeset so the release notes carry the same disclosure.

Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
@coderdan

Copy link
Copy Markdown
Contributor Author

Comprehensive human and AI review. Multiple agents: Copilot, Coderabbit and Claude (Fable) - all findings addressed.

@coderdan
coderdan merged commit a8c9602 into main Jul 15, 2026
11 checks passed
@coderdan
coderdan deleted the feat/cli-anonymous-telemetry branch July 15, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants