Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/cli-anonymous-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'stash': minor
---

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

Only coarse events are collected — command name, CLI version, OS/arch, Node
version, success/failure, duration, and a coarse caller class (e.g.
`claude-code`, `cursor`, `interactive`) derived from environment markers so we
can gauge agent- vs human-driven usage. Events carry a random install
identifier (a locally generated UUID, not derived from any machine or user
attribute) used only to de-duplicate events in aggregate. Plaintext, schema,
table/column names,
connection strings, argument values, and any session/trace identifier are never
collected — enforced by a property-key allowlist at the emitter boundary plus
closed-vocabulary coercion of every argv- or error-derived value (unrecognised
commands, subcommands, and error class names all collapse to `<other>`). A
one-time notice is shown on first run, and nothing is sent on that run.

Telemetry is off by default in CI and can be disabled with `DO_NOT_TRACK=1`
(the cross-tool standard), `STASH_TELEMETRY_DISABLED=1`, or
`stash telemetry disable` (persisted to `~/.cipherstash/telemetry.json`).

Events are sent via a first-party proxy and never block or slow the CLI. The
feature ships dormant — no events are sent until a PostHog project key is
embedded at release. Updates the `stash-cli` skill to document the command and
opt-out controls.
11 changes: 11 additions & 0 deletions .changeset/wizard-analytics-privacy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@cipherstash/wizard': patch
---

Align the wizard's analytics with the `stash` CLI's telemetry privacy contract.
The wizard now honors `DO_NOT_TRACK`, `STASH_TELEMETRY_DISABLED`, and CI
auto-detection; uses a random per-session identifier instead of one derived
from username@hostname; disables IP→geo resolution; and reports error events as
fixed labels / error class names instead of raw messages (which could embed
schema names or connection details). Analytics remain dormant unless a PostHog
key is configured at build time.
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,10 @@ jobs:
# changesets/action writes a token .npmrc that shadows OIDC and
# every publish fails with E404 (see npm/cli#8976).
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Embeds the CLI's PostHog project key at build time (see
# packages/cli/tsup.config.ts). A repo *variable*, not a secret: the
# key is public and write-only (like a web SDK key). Unset until GA, so
# every release before it is flipped ships telemetry-dormant. This is
# the single go-live switch — set it with:
# gh variable set STASH_POSTHOG_KEY --repo cipherstash/stack --body '<phc_...>'
STASH_POSTHOG_KEY: ${{ vars.STASH_POSTHOG_KEY }}
19 changes: 14 additions & 5 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,20 @@ exercise the same code paths.
test actually asserts on the string — premature extraction is worse
than copy-paste here. For literals tests don't touch (e.g. command
names like `init`, `eql install`), keep them inline.
- **Telemetry.** The CLI source no longer imports `posthog-node` (analytics
moved to `packages/wizard`). The dep is still listed in `package.json`
and should be removed in a follow-up. If you re-introduce telemetry to
the CLI, gate construction on an env var (the wizard's
`getClient()` pattern) so E2E tests can no-op it.
- **Telemetry.** The CLI has anonymous, opt-out usage analytics in
`src/telemetry/` (posthog-node, loaded lazily only when an event is
actually sent). It ships dormant — a real project key is embedded only by
the release build (`STASH_POSTHOG_KEY` repo variable → tsup define) — and
is force-disabled in every pty e2e child via `STASH_TELEMETRY_DISABLED=1`
in `tests/helpers/pty.ts`. Two contracts to preserve when touching it:
(1) event VALUES are closed vocabularies — `classifyCommand` /
`classifyErrorType` in `src/telemetry/classify-command.ts` must wrap
anything argv- or error-derived before emit; (2) never intercept
`process.exit` with a thrown signal — @clack/core exits from keypress
handlers and several commands have broad catches, so interception breaks
cancel flows (tried and reverted; see `src/cli/exit.ts`). Commands that
terminate via deep `process.exit()` are simply not tracked; cooperative
exits use `throw new CliExit(code)` from verified-unwindable sites only.

## Plan and rationale

Expand Down
82 changes: 75 additions & 7 deletions packages/cli/src/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import * as p from '@clack/prompts'
import { CliExit } from '../cli/exit.js'
import { renderCommandHelp } from '../cli/help.js'
// Commands that depend on @cipherstash/stack are lazy-loaded in the switch below.
import {
Expand All @@ -31,11 +32,22 @@ import {
manifestCommand,
planCommand,
statusCommand,
telemetryCommand,
testConnectionCommand,
upgradeCommand,
wizardCommand,
} from '../commands/index.js'
import { messages } from '../messages.js'
import {
classifyCommand,
classifyErrorType,
} from '../telemetry/classify-command.js'
import {
initTelemetry,
maybeShowFirstRunNotice,
shutdownTelemetry,
trackCommand,
} from '../telemetry/index.js'

function isModuleNotFound(err: unknown): boolean {
return (
Expand Down Expand Up @@ -72,7 +84,7 @@ async function requireStack<T>(importFn: () => Promise<T>): Promise<T> {
Install it with: ${prodInstallCommand(PM, '@cipherstash/stack')}
Or run: ${STASH} init`,
)
process.exit(1) as never
throw new CliExit(1)
}
throw err
}
Expand All @@ -92,6 +104,7 @@ Commands:
wizard AI-guided encryption setup (reads your codebase)
doctor Diagnose install problems (native binaries, runtime)
manifest Print the structured, versioned command surface (--json for docs/agents)
telemetry <sub> Manage anonymous usage analytics (status, enable, disable)

eql install Scaffold stash.config.ts (if missing) and install EQL extensions
eql upgrade Upgrade EQL extensions to the latest version
Expand Down Expand Up @@ -228,7 +241,7 @@ async function runEqlCommand(
p.log.error(`${messages.eql.unknownSubcommand}: ${sub ?? '(none)'}`)
console.log()
console.log(HELP)
process.exit(1)
throw new CliExit(1)
}
}

Expand Down Expand Up @@ -292,7 +305,7 @@ async function runDbCommand(
p.log.error(`${messages.db.unknownSubcommand}: ${sub ?? '(none)'}`)
console.log()
console.log(HELP)
process.exit(1)
throw new CliExit(1)
}
}

Expand Down Expand Up @@ -367,15 +380,15 @@ async function runEncryptCommand(
p.log.error(`Unknown encrypt subcommand: ${sub ?? '(none)'}`)
console.log()
console.log(HELP)
process.exit(1)
throw new CliExit(1)
}
}

function requireValue(values: Record<string, string>, key: string): string {
const v = values[key]
if (!v) {
p.log.error(`Missing required --${key} value.`)
process.exit(1)
throw new CliExit(1)
}
return v
}
Expand All @@ -400,7 +413,7 @@ async function runSchemaCommand(
p.log.error(`Unknown schema subcommand: ${sub ?? '(none)'}`)
console.log()
console.log(HELP)
process.exit(1)
throw new CliExit(1)
}
}

Expand Down Expand Up @@ -431,6 +444,58 @@ export async function run() {
return
}

// Anonymous, opt-out usage analytics. The notice shows once (to stderr) and
// the run that shows it sends nothing; both are no-ops when telemetry is off.
initTelemetry(pkg.version)
maybeShowFirstRunNotice(STASH)

const startedAt = Date.now()
let success = true
let errorType: string | undefined
let exitCode: number | undefined

// Outcomes are tracked for commands that RETURN, THROW, or throw CliExit (the
// cooperative exit used by main.ts's own helpers and the outermost cancel
// handlers — see cli/exit.ts). Deep `process.exit()` calls terminate without
// an event by design: intercepting them globally proved unsafe (clack exits
// from keypress handlers; broad catches swallowed the signal).
try {
await dispatch(command, subcommand, commandArgs, flags, values)
} catch (err) {
if (err instanceof CliExit) {
exitCode = err.code
success = err.code === 0
} else {
success = false
errorType = classifyErrorType(err)
// Rethrow to bootstrap's handler ("Fatal error" + exit 1). The finally
// below still runs — including the awaited flush — before propagation.
throw err
}
} finally {
// Coerce command/subcommand to a known vocabulary before emit so a free-text
// positional (e.g. a `stash wizard "<prompt>"` description) never leaves.
const safe = classifyCommand(command, subcommand)
trackCommand({
command: safe.command,
subcommand: safe.subcommand,
success,
durationMs: Date.now() - startedAt,
errorType,
})
await shutdownTelemetry()
}

if (exitCode !== undefined) process.exit(exitCode)
}

async function dispatch(
command: string,
subcommand: string | undefined,
commandArgs: string[],
flags: Record<string, boolean>,
values: Record<string, string>,
) {
switch (command) {
case 'init':
await initCommand(flags, values)
Expand Down Expand Up @@ -473,6 +538,9 @@ export async function run() {
// the native binary is missing.
manifestCommand({ json: flags.json, version: pkg.version })
break
case 'telemetry':
await telemetryCommand(subcommand)
break
case 'wizard': {
// Forward everything after `stash wizard` verbatim. The wizard package
// owns its own flag parsing; we don't try to interpret its surface
Expand All @@ -492,6 +560,6 @@ export async function run() {
default:
console.error(`${messages.cli.unknownCommand}: ${command}\n`)
console.log(HELP)
process.exit(1)
throw new CliExit(1)
}
}
24 changes: 24 additions & 0 deletions packages/cli/src/cli/exit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Cooperative exit for first-party command code: `throw new CliExit(code)`
* instead of `process.exit(code)`.
*
* Thrown from a command, it unwinds to `run()` (bin/main.ts), which records the
* outcome for telemetry, flushes, and then performs the real `process.exit`
* with the carried code. This only works from call stacks that reach `run()`'s
* catch without an intervening broad `catch` — which is why ONLY sites verified
* to unwind cleanly use it (main.ts's own dispatch helpers, the telemetry
* command, and the outermost CancelledError handlers of init/plan/impl).
*
* Deep exits stay `process.exit()` deliberately. An earlier version intercepted
* `process.exit` globally with a thrown signal; the review showed that breaks
* code we don't control — @clack/core calls `process.exit(0)` from a keypress
* handler (no enclosing try ⇒ uncaughtException), and several command-level
* broad catches swallowed the signal, continuing past hard stops. Those
* invocations are simply not tracked; see the telemetry module doc.
*/
export class CliExit extends Error {
constructor(readonly code: number) {
super(`exit ${code}`)
this.name = 'CliExit'
}
}
14 changes: 14 additions & 0 deletions packages/cli/src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,20 @@ export const registry: CommandGroup[] = [
},
],
},
{
name: 'telemetry',
summary: 'Manage anonymous usage analytics',
long: [
'Manage the anonymous, opt-out usage analytics the CLI collects to',
'improve the tool. `status` (the default) reports whether telemetry is',
'on and which setting governs it; `enable` / `disable` write your saved',
'preference. Telemetry is also disabled by the DO_NOT_TRACK or',
'STASH_TELEMETRY_DISABLED environment variables and automatically in CI.',
'No plaintext, schema, table/column names, or connection details are',
'ever collected. See https://cipherstash.com/docs/reference/cli.',
].join('\n'),
examples: ['telemetry status', 'telemetry disable', 'telemetry enable'],
},
],
},
{
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/impl/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as p from '@clack/prompts'
import { CliExit } from '../../cli/exit.js'
import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js'
import {
effectiveStep,
Expand Down Expand Up @@ -291,7 +292,9 @@ export async function implCommand(
} catch (err) {
if (err instanceof CancelledError) {
p.cancel('Cancelled.')
process.exit(0)
// Cooperative exit: unwinds to run() so the cancel is tracked and the
// telemetry flush completes before the process exits 0 (see cli/exit.ts).
throw new CliExit(0)
}
throw err
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export { initCommand } from './init/index.js'
export { manifestCommand } from './manifest/index.js'
export { planCommand } from './plan/index.js'
export { statusCommand } from './status/index.js'
export { telemetryCommand } from './telemetry/index.js'
export { wizardCommand } from './wizard/index.js'
5 changes: 4 additions & 1 deletion packages/cli/src/commands/init/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as p from '@clack/prompts'
import { CliExit } from '../../cli/exit.js'
import { HANDOFF_CHOICES } from '../impl/steps/how-to-proceed.js'
import { planCommand } from '../plan/index.js'
import { createBaseProvider } from './providers/base.js'
Expand Down Expand Up @@ -140,7 +141,9 @@ export async function initCommand(
} catch (err) {
if (err instanceof CancelledError) {
p.cancel('Setup cancelled.')
process.exit(0)
// Cooperative exit: unwinds to run() so the cancel is tracked and the
// telemetry flush completes before the process exits 0 (see cli/exit.ts).
throw new CliExit(0)
}
throw err
}
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/plan/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { readManifest } from '@cipherstash/migrate'
import * as p from '@clack/prompts'
import { CliExit } from '../../cli/exit.js'
import {
HANDOFF_CHOICES,
howToProceedStep,
Expand Down Expand Up @@ -224,7 +225,9 @@ export async function planCommand(
} catch (err) {
if (err instanceof CancelledError) {
p.cancel('Cancelled.')
process.exit(0)
// Cooperative exit: unwinds to run() so the cancel is tracked and the
// telemetry flush completes before the process exits 0 (see cli/exit.ts).
throw new CliExit(0)
}
throw err
}
Expand Down
Loading
Loading