mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-07-03 12:32:32 +08:00
feat(telemetry): opt-in anonymous usage analytics via PostHog (#2863)
Opt-in PostHog product analytics living in the long-running worker. Consent precedence DO_NOT_TRACK > CLAUDE_MEM_TELEMETRY > telemetry.json > default OFF; whitelist-only scrubber; claude-mem telemetry CLI; install-flow consent prompt as the final step; async dependency installs with live spinner heartbeat; full privacy docs. Ships dark until the publishable key lands.
This commit is contained in:
@@ -83,6 +83,7 @@
|
||||
"configuration/litellm-gateway",
|
||||
"configuration/custom-anthropic-backends",
|
||||
"modes",
|
||||
"telemetry",
|
||||
"development",
|
||||
"troubleshooting",
|
||||
"platform-integration",
|
||||
|
||||
108
docs/public/telemetry.mdx
Normal file
108
docs/public/telemetry.mdx
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
title: "Telemetry"
|
||||
description: "Opt-in, anonymous usage analytics — disabled by default, fully documented, easy to turn off"
|
||||
---
|
||||
|
||||
# Telemetry
|
||||
|
||||
Claude-mem includes **opt-in** anonymous usage analytics (via PostHog) to help prioritize fixes and features.
|
||||
|
||||
**It ships disabled.** Nothing is ever sent unless you explicitly say yes — either to the one-time question at the end of `npx claude-mem install` ("Would you mind sharing anonymized usage data with CMEM?"), or by running:
|
||||
|
||||
```bash
|
||||
npx claude-mem telemetry enable
|
||||
```
|
||||
|
||||
There is no auto-enable and no "anonymous by default" phase. The installer asks once, after installation completes — your answer (either way) is remembered and never re-asked, the prompt is skipped entirely when `DO_NOT_TRACK` is set or in CI/non-interactive installs, and declining writes `enabled: false`. If you never say yes (and never set `CLAUDE_MEM_TELEMETRY=1`), zero bytes of telemetry leave your machine.
|
||||
|
||||
## What is collected
|
||||
|
||||
When enabled, events are anonymous and identified only by a random install UUID (`crypto.randomUUID()`, generated locally on first use). Events are sent with `$process_person_profile: false`, so PostHog never builds a person profile.
|
||||
|
||||
Every event property passes through a strict whitelist scrubber — any key not in this table is silently dropped before sending:
|
||||
|
||||
| Field | Example | Description |
|
||||
|---|---|---|
|
||||
| event name | `session_compressed` | Which of the four events occurred |
|
||||
| `distinct_id` | `7f3c…` (random UUID) | Anonymous install ID — not derived from you or your machine |
|
||||
| `version` | `13.4.2` | claude-mem version |
|
||||
| `os` | `darwin` | Operating system platform |
|
||||
| `arch` | `arm64` | CPU architecture |
|
||||
| `runtime` | `bun` | `bun` or `node` |
|
||||
| `runtime_version` | `1.2.0` | Runtime version string |
|
||||
| `duration_ms` | `1843` | How long an operation took |
|
||||
| `outcome` | `success` | Coarse result: success / failure |
|
||||
| `error_category` | `db_error` | Coarse error bucket — never an error message |
|
||||
| `locale` | `en-US` | Language tag |
|
||||
| `is_ci` | `false` | Whether running in CI |
|
||||
|
||||
### Events
|
||||
|
||||
| Event | When |
|
||||
|---|---|
|
||||
| `worker_started` | The background worker service finishes starting |
|
||||
| `session_compressed` | A session compression completes |
|
||||
| `search_performed` | A memory search runs (never the query text — only the outcome) |
|
||||
| `error_occurred` | The worker hits an error (category only, never the message) |
|
||||
|
||||
## What is NEVER collected
|
||||
|
||||
| Never collected | Notes |
|
||||
|---|---|
|
||||
| Prompts or conversation content | Not even truncated or hashed |
|
||||
| File paths or directory names | Including cwd, transcript paths, data dir |
|
||||
| Source code | In any form |
|
||||
| Project or repository names | Including git remotes and branch names |
|
||||
| Search queries | Only the fact that a search happened |
|
||||
| Error messages or stack traces | Only a coarse category string |
|
||||
| IP addresses | Never attached to events by the client; the analytics project is configured to discard sender IPs on ingest |
|
||||
| Hardware or machine identifiers | Not even hashed MAC addresses or hostnames |
|
||||
| Environment variable values | Ever |
|
||||
| Emails, usernames, or any PII | Ever |
|
||||
|
||||
These are enforced in code: properties go through a whitelist (only the ten fields above survive), not a blocklist.
|
||||
|
||||
## How to disable (four ways)
|
||||
|
||||
Any one of these keeps telemetry off — they are checked in this order, first match wins:
|
||||
|
||||
1. **`DO_NOT_TRACK`** — the [universal opt-out](https://consoledonottrack.com). Set `DO_NOT_TRACK=1` and telemetry is forced off, overriding everything else.
|
||||
2. **`CLAUDE_MEM_TELEMETRY=0`** (also `false` / `off`) — environment override. (`CLAUDE_MEM_TELEMETRY=1` conversely forces it on.)
|
||||
3. **Telemetry config file** — `enabled: false` in `telemetry.json` (see below).
|
||||
4. **CLI command**:
|
||||
```bash
|
||||
npx claude-mem telemetry disable
|
||||
```
|
||||
|
||||
Check the current state — and which of the four layers decided it — anytime:
|
||||
|
||||
```bash
|
||||
npx claude-mem telemetry status
|
||||
```
|
||||
|
||||
## Debug mode
|
||||
|
||||
Want to see exactly what would be sent? Set:
|
||||
|
||||
```bash
|
||||
CLAUDE_MEM_TELEMETRY_DEBUG=1
|
||||
```
|
||||
|
||||
With debug mode on (and telemetry enabled), every would-be event payload is printed to stderr and **nothing is sent over the network**.
|
||||
|
||||
## Where the config lives
|
||||
|
||||
Consent and the anonymous install ID are stored in `telemetry.json` inside the claude-mem data directory:
|
||||
|
||||
- Default: `~/.claude-mem/telemetry.json`
|
||||
- Or `$CLAUDE_MEM_DATA_DIR/telemetry.json` if you've overridden the data dir
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"installId": "<random UUID>",
|
||||
"decidedAt": "2026-06-09T21:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Delete the file to reset to the default (off, no install ID).
|
||||
@@ -140,6 +140,7 @@
|
||||
"ioredis": "^5.10.1",
|
||||
"pg": "^8.20.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"posthog-node": "^5.36.8",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"shell-quote": "^1.8.3",
|
||||
|
||||
92
plans/2026-06-09-opt-in-posthog-telemetry.md
Normal file
92
plans/2026-06-09-opt-in-posthog-telemetry.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Opt-In PostHog Telemetry for claude-mem
|
||||
|
||||
**Goal:** One mergeable PR adding privacy-respecting, OPT-IN product analytics to claude-mem using the official `posthog-node` SDK, gated by a consent resolver and a whitelist property scrubber. Ships dark: default OFF, nothing leaves the machine without explicit consent.
|
||||
|
||||
**Origin:** Research on branch `claude/analytics-platforms-comparison-o58erd` (commit 256b3584f, `claude-mem-analytics-research-transcript.txt`) + the official PostHog skill (`npx skills use posthog/ai-plugin@instrument-product-analytics`).
|
||||
|
||||
## Architecture decision (locked — do not re-litigate)
|
||||
|
||||
The research transcript proposed a SQLite `telemetry_events` spool table drained by a worker flush job. **We are NOT building that.** The spool solved "short-lived hook processes exit before the SDK's in-memory queue flushes" — but in claude-mem all the events worth tracking (compression, search, startup, errors) occur **inside the long-running worker service**, which is exactly where a `posthog-node` client is designed to live. So:
|
||||
|
||||
- `posthog-node` SDK initialized lazily, once, in the worker. SDK handles batching/retry/flush.
|
||||
- NO new SQLite migration. NO custom `/batch/` wire format. NO custom retry loop.
|
||||
- Every capture goes through ONE wrapper: consent gate → whitelist scrub → debug-print or `posthog.capture()`. The wrapper never throws and never blocks.
|
||||
- `cli.command` events from short-lived npx processes are **scope-cut from v1** (future: POST to worker HTTP). Keeps the PR small.
|
||||
|
||||
What we keep from the research (the trust-critical layer):
|
||||
- Opt-in, default OFF. Consent precedence: `DO_NOT_TRACK` (truthy → forced off) > `CLAUDE_MEM_TELEMETRY` env (`0/false/off` → off, `1/true/on` → on) > `telemetry.json` config > default OFF.
|
||||
- Random install UUID (crypto.randomUUID, first use) + consent stored in `~/.claude-mem/telemetry.json` (data dir via existing helper; survives DB resets). UUID = PostHog `distinctId`.
|
||||
- Whitelist scrubber. Allowed property keys ONLY: `version`, `os`, `arch`, `runtime`, `runtime_version`, `duration_ms`, `outcome`, `error_category`, `locale`, `is_ci`, plus the event name. Everything else dropped. NEVER: paths, project names, git remotes, prompts, source code, IPs, hardware IDs (even hashed), env values, emails.
|
||||
- `CLAUDE_MEM_TELEMETRY_DEBUG=1` → print would-be payloads to stderr, send nothing.
|
||||
- `claude-mem telemetry [status|enable|disable]` CLI command.
|
||||
- `docs/public/telemetry.mdx` enumerating every field collected and not-collected.
|
||||
- Unit tests for the consent resolver and the scrubber.
|
||||
|
||||
## Allowed APIs (verified)
|
||||
|
||||
**posthog-node** (https://posthog.com/docs/libraries/node — re-read before coding):
|
||||
- `new PostHog(apiKey, { host, flushAt, flushInterval })` — host default `https://us.i.posthog.com`; key/host via `CLAUDE_MEM_TELEMETRY_KEY` / `CLAUDE_MEM_TELEMETRY_HOST` env with a hardcoded **publishable project token** fallback constant (publishable tokens are safe to embed — verified at posthog.com/docs/api: capture endpoints are public POST-only, no rate limits, 20MB body cap).
|
||||
- `client.capture({ distinctId, event, properties })` — fire-and-forget, queues in memory.
|
||||
- Set `properties.$process_person_profile = false` on every event (anonymous events — cheaper, privacy-aligned).
|
||||
- `await client.shutdown()` — flush on worker graceful stop ONLY. Never on the capture path.
|
||||
|
||||
**Codebase facts (verified 2026-06-09):**
|
||||
- Migration runner is at version 34 (`src/services/sqlite/migrations/runner.ts`) — we deliberately do not touch it.
|
||||
- Tests: `bun test`, files at `tests/**/*.test.ts`, import from `bun:test`; copy style from `tests/json-utils.test.ts`.
|
||||
- Worker code: `src/services/worker/` (Express `http/`, `SettingsManager.ts`, providers). CLI commands: `src/npx-cli/commands/` (e.g. `doctor.ts`).
|
||||
- `@clack/prompts` is already a dependency (used by installer flows).
|
||||
- Event naming: snake_case (per official PostHog skill).
|
||||
|
||||
## Phase 1 — Discovery + consent & scrub core (pure logic, no network, no SDK)
|
||||
|
||||
1. Grep for existing `posthog`/`telemetry` references (expect only incidental SDK-flag mentions in `ProviderObservationGenerator.ts`, `ClaudeProvider.ts`, `ChromaMcpManager.ts` — read them to confirm they're unrelated, do not modify).
|
||||
2. Find the existing data-dir helper that resolves `CLAUDE_MEM_DATA_DIR` / `~/.claude-mem` (grep `CLAUDE_MEM_DATA_DIR` in `src/`), and the `readJsonSafe` util (`src/utils/json-utils.ts`). Reuse both.
|
||||
3. Create `src/services/telemetry/consent.ts`:
|
||||
- `resolveTelemetryConsent(env, config): boolean` — pure, precedence as locked above.
|
||||
- `loadTelemetryConfig() / saveTelemetryConfig()` — `telemetry.json` in data dir: `{ enabled: boolean, installId: string, decidedAt: string }`.
|
||||
- `getOrCreateInstallId(): string`.
|
||||
4. Create `src/services/telemetry/scrub.ts`: `scrubProperties(props): Record<string, string|number|boolean>` — whitelist filter, primitive-only values, truncate strings >200 chars.
|
||||
5. Tests: `tests/telemetry/consent.test.ts`, `tests/telemetry/scrub.test.ts` (style of `tests/json-utils.test.ts`). Cover: DO_NOT_TRACK=1 beats enabled config; env off beats config on; default off; unknown keys dropped; nested objects dropped; denylisted-looking keys (path, cwd, prompt) dropped even if someone whitelists by mistake — add an explicit denylist assertion test.
|
||||
|
||||
**Verify:** `bun test tests/telemetry/` green. No imports from `posthog-node` anywhere yet.
|
||||
|
||||
## Phase 2 — SDK wrapper + worker capture sites
|
||||
|
||||
1. `npm install posthog-node` (package manager command, do not hand-edit package.json). Check `esbuild`/build config: if worker bundle has an `external` array pattern (it did for `bullmq`/`pg`), determine whether `posthog-node` bundles cleanly; mirror the existing convention.
|
||||
2. Create `src/services/telemetry/telemetry.ts`:
|
||||
- Lazy singleton `getClient()` — constructs PostHog only on first consented capture.
|
||||
- `captureEvent(event: string, props?: Record<string, unknown>): void` — consent gate → base props (version from package.json/version helper, `os`, `arch`, `runtime: 'bun'|'node'`, `runtime_version`, `is_ci`, `locale`) → scrub → if `CLAUDE_MEM_TELEMETRY_DEBUG=1` print JSON to stderr and return → else `client.capture({...})`. Entire body in try/catch that swallows (debug-log only). Synchronous, O(1).
|
||||
- `shutdownTelemetry(): Promise<void>` — flush with a 3s race timeout; never rejects.
|
||||
3. Wire capture sites (find exact locations by reading worker code; keep each insertion to 1-3 lines):
|
||||
- `worker_started` — worker service startup completion (`src/services/worker-service.ts` or equivalent startup path).
|
||||
- `session_compressed` — where a session compression/summarization completes successfully (with `duration_ms`, `outcome`).
|
||||
- `search_performed` — `SearchManager` / search HTTP route entry (NO query text — only `outcome`).
|
||||
- `error_occurred` — central worker error handler if one exists (with `error_category` only — a coarse enum string, never `error.message`).
|
||||
- `shutdownTelemetry()` in the worker's graceful-stop path (find existing SIGTERM/stop handler).
|
||||
4. Anti-pattern guards: no `posthog` import outside `src/services/telemetry/`; no `capture(` call site passing raw error messages, paths, or query strings; key only via env/constant.
|
||||
|
||||
**Verify:** `npm run build` (or the repo's build script) passes. `bun test` green. Grep checks: `grep -rn "posthog" src/ | grep -v services/telemetry` returns nothing; with consent absent, boot worker with `CLAUDE_MEM_TELEMETRY_DEBUG=1` → zero telemetry stderr lines (gate is before debug print? NO — debug prints only when consented; with no consent, nothing prints. Confirm that ordering in code: consent gate FIRST, debug second).
|
||||
|
||||
## Phase 3 — CLI command + docs
|
||||
|
||||
1. Read `src/npx-cli/commands/doctor.ts` and the command router that registers it; copy the registration pattern exactly.
|
||||
2. Create `src/npx-cli/commands/telemetry.ts`:
|
||||
- `status` (default): prints enabled/disabled, which layer decided it (DO_NOT_TRACK / env / config / default), install ID, and the docs URL.
|
||||
- `enable`: `@clack/prompts` confirm showing the exact field list collected + "no prompts, paths, code, or project names — ever" + docs link; on yes, write config with installId.
|
||||
- `disable`: write `enabled: false`. No prompt.
|
||||
3. Create `docs/public/telemetry.mdx` + add nav entry in `docs/public/docs.json` (check existing `docs.json` structure first): tables of every field collected / explicitly never collected, all four disable methods (DO_NOT_TRACK, env var, config, CLI command), debug mode, where telemetry.json lives, the fact it's opt-in and ships disabled.
|
||||
|
||||
**Verify:** run the CLI command locally (`bun src/npx-cli/... telemetry status` per repo's dev pattern) — status reflects env overrides correctly (test with `DO_NOT_TRACK=1`).
|
||||
|
||||
## Phase 4 — Verification + PR
|
||||
|
||||
1. Full `bun test` and the build script. Fix anything broken by the new dep.
|
||||
2. Anti-pattern sweep (all must pass):
|
||||
- `grep -rn "from 'posthog-node'" src/ | grep -v services/telemetry/telemetry` → empty.
|
||||
- `grep -rn "captureEvent(" src/` → every call site's props are literal objects containing only whitelisted keys.
|
||||
- No personal API key (`phx_`) anywhere; only publishable (`phc_`) token constant/env.
|
||||
- `telemetry.json` never written without explicit user action (enable command) — grep `saveTelemetryConfig` call sites.
|
||||
3. Branch `feat/opt-in-telemetry` from `main`, commit (conventional: `feat(telemetry): opt-in anonymous usage analytics via PostHog`), push, `gh pr create` with: summary, the architecture-decision paragraph (worker-resident SDK, no spool), the collected/never-collected table, and screenshots/output of `telemetry status`.
|
||||
4. Hand off to `claude-mem:babysit` for the PR.
|
||||
|
||||
**Done means:** PR open, CI green, telemetry provably inert by default (fresh install sends nothing), and the privacy docs page renders.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,7 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { loadTelemetryConfig, saveTelemetryConfig } from '../../services/telemetry/consent.js';
|
||||
import { spawnHidden } from '../../shared/spawn.js';
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
@@ -49,6 +51,24 @@ async function runTasks(tasks: TaskDescriptor[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick a task's spinner message with elapsed seconds. The multi-minute
|
||||
* dependency installs used to sit on one static message (and previously a
|
||||
* blocked event loop), which read as a stalled install. Returns a stop
|
||||
* function for a finally block. Non-interactive runs get the label once —
|
||||
* a per-second console.log line would spam CI logs.
|
||||
*/
|
||||
function startHeartbeat(message: (msg: string) => void, label: string): () => void {
|
||||
message(label);
|
||||
if (!isInteractive) return () => {};
|
||||
const started = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - started) / 1000);
|
||||
message(`${label} ${pc.dim(`(${elapsed}s — still working)`)}`);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
async function bufferConsole<T>(fn: () => Promise<T>): Promise<{ result: T; output: string }> {
|
||||
if (!isInteractive) {
|
||||
const result = await fn();
|
||||
@@ -632,14 +652,14 @@ function copyPluginToCache(version: string): void {
|
||||
* confirmed ERESOLVE token, announced loudly. `--ignore-scripts` is the default
|
||||
* (v12.6.2 lesson: a transitive postinstall can hang the install).
|
||||
*/
|
||||
function runNpmInstallInMarketplace(summary: InstallSummary): void {
|
||||
async function runNpmInstallInMarketplace(summary: InstallSummary): Promise<void> {
|
||||
const marketplaceDir = marketplaceDirectory();
|
||||
const packageJsonPath = join(marketplaceDir, 'package.json');
|
||||
|
||||
if (!existsSync(packageJsonPath)) return;
|
||||
|
||||
const baseFlags = ['install', '--omit=dev', '--ignore-scripts'];
|
||||
const strictResult = runNpmStrict(marketplaceDir, baseFlags);
|
||||
const strictResult = await runNpmStrict(marketplaceDir, baseFlags);
|
||||
if (strictResult.code === 0) return;
|
||||
|
||||
if (strictResult.timedOut) {
|
||||
@@ -665,7 +685,7 @@ function runNpmInstallInMarketplace(summary: InstallSummary): void {
|
||||
log.warn('npm reported an ERESOLVE peer-dependency conflict in marketplace deps; retrying once with --legacy-peer-deps.');
|
||||
log.warn(extractEresolveBlock(strictResult.stderr));
|
||||
|
||||
const legacyResult = runNpmStrict(marketplaceDir, [...baseFlags, '--legacy-peer-deps']);
|
||||
const legacyResult = await runNpmStrict(marketplaceDir, [...baseFlags, '--legacy-peer-deps']);
|
||||
if (legacyResult.code === 0) {
|
||||
summary.warnings.push({
|
||||
component: 'marketplace-npm-install',
|
||||
@@ -1231,6 +1251,38 @@ async function submitOnlineSignup(payload: { email: string; note: string; versio
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Final step of the install flow: ask for anonymized-telemetry consent.
|
||||
* Asked ONCE — any existing telemetry.json (enabled or not) means the user
|
||||
* already decided, and we never re-nag. Declining is persisted so re-installs
|
||||
* stay silent. Respects DO_NOT_TRACK (skip entirely: they already answered),
|
||||
* CI, and non-TTY. See docs/public/telemetry.mdx for what is/isn't collected.
|
||||
*/
|
||||
async function promptTelemetryOptIn(): Promise<void> {
|
||||
if (!isInteractive) return;
|
||||
if (process.env.CI) return;
|
||||
const dnt = process.env.DO_NOT_TRACK;
|
||||
if (dnt !== undefined && dnt !== '' && dnt !== '0' && dnt !== 'false') return;
|
||||
if (loadTelemetryConfig() !== null) return;
|
||||
|
||||
p.log.message(pc.dim(
|
||||
'Anonymous install ID only — no prompts, file paths, code, or project names, ever.\n'
|
||||
+ 'Details: https://docs.claude-mem.ai/telemetry · Change anytime: claude-mem telemetry disable',
|
||||
));
|
||||
const consent = await p.confirm({
|
||||
message: 'Would you mind sharing anonymized usage data with CMEM? We use this data to make the product better.',
|
||||
initialValue: true,
|
||||
});
|
||||
if (p.isCancel(consent)) return;
|
||||
|
||||
saveTelemetryConfig({
|
||||
enabled: consent === true,
|
||||
installId: randomUUID(),
|
||||
decidedAt: new Date().toISOString(),
|
||||
});
|
||||
log.success(consent ? 'Thanks! Anonymized usage sharing is on.' : 'No problem — telemetry stays off.');
|
||||
}
|
||||
|
||||
async function promptCmemOnlineOptIn(version: string): Promise<void> {
|
||||
// Interactive-only, and easy to turn off for CI / scripted installs.
|
||||
if (!isInteractive) return;
|
||||
@@ -1485,9 +1537,13 @@ async function runInstallCommandInner(options: InstallOptions, summary: InstallS
|
||||
const { version: uvVersion } = await ensureUv(summary);
|
||||
const cacheDir = pluginCacheDirectory(version);
|
||||
if (!isInstallCurrent(cacheDir, version)) {
|
||||
message('Installing plugin dependencies…');
|
||||
const { bunPath } = await ensureBun();
|
||||
await installPluginDependencies(cacheDir, bunPath);
|
||||
const stopHeartbeat = startHeartbeat(message, 'Installing plugin dependencies (bun install)…');
|
||||
try {
|
||||
await installPluginDependencies(cacheDir, bunPath);
|
||||
} finally {
|
||||
stopHeartbeat();
|
||||
}
|
||||
writeInstallMarker(cacheDir, version, bunVersion, uvVersion);
|
||||
}
|
||||
return `Runtime ready (Bun ${bunVersion}, uv ${uvVersion}) ${pc.green('OK')}`;
|
||||
@@ -1507,12 +1563,16 @@ async function runInstallCommandInner(options: InstallOptions, summary: InstallS
|
||||
tasks.push({
|
||||
title: 'Installing marketplace dependencies',
|
||||
task: async (message) => {
|
||||
message('Running npm install...');
|
||||
// runNpmInstallInMarketplace throws InstallAbortError on a real
|
||||
// failure (non-ERESOLVE, or ERESOLVE that --legacy-peer-deps could
|
||||
// not fix). We deliberately do NOT swallow it here — the top-level
|
||||
// handler turns it into "Installation Aborted" + exit 1.
|
||||
runNpmInstallInMarketplace(summary);
|
||||
const stopHeartbeat = startHeartbeat(message, 'Running npm install…');
|
||||
try {
|
||||
await runNpmInstallInMarketplace(summary);
|
||||
} finally {
|
||||
stopHeartbeat();
|
||||
}
|
||||
return `Dependencies installed ${pc.green('OK')}`;
|
||||
},
|
||||
});
|
||||
@@ -1722,6 +1782,9 @@ async function runInstallCommandInner(options: InstallOptions, summary: InstallS
|
||||
|
||||
if (isInteractive) {
|
||||
p.note(nextSteps.join('\n'), 'Next Steps');
|
||||
// Deliberately the last interaction of the flow: consent is asked after
|
||||
// the product is installed and working, never as a gate in front of it.
|
||||
await promptTelemetryOptIn();
|
||||
if (failedIDEs.length > 0) {
|
||||
p.outro(pc.yellow('claude-mem installed with some IDE setup failures.'));
|
||||
} else {
|
||||
|
||||
156
src/npx-cli/commands/telemetry.ts
Normal file
156
src/npx-cli/commands/telemetry.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* `npx claude-mem telemetry [status|enable|disable]` — manage opt-in anonymous
|
||||
* usage analytics. Telemetry ships disabled; nothing is ever sent without
|
||||
* explicit consent via `telemetry enable` (or CLAUDE_MEM_TELEMETRY=1).
|
||||
*
|
||||
* Full privacy documentation: https://docs.claude-mem.ai/telemetry
|
||||
*/
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import {
|
||||
explainTelemetryConsent,
|
||||
loadTelemetryConfig,
|
||||
saveTelemetryConfig,
|
||||
getOrCreateInstallId,
|
||||
getTelemetryConfigPath,
|
||||
type TelemetryConsentSource,
|
||||
} from '../../services/telemetry/consent.js';
|
||||
|
||||
const DOCS_URL = 'https://docs.claude-mem.ai/telemetry';
|
||||
|
||||
const COLLECTED_FIELDS = [
|
||||
'version claude-mem version (e.g. 13.4.2)',
|
||||
'os platform (darwin / linux / win32)',
|
||||
'arch CPU architecture (arm64 / x64)',
|
||||
'runtime bun or node',
|
||||
'runtime_version runtime version string',
|
||||
'duration_ms how long an operation took',
|
||||
'outcome success / failure',
|
||||
'error_category coarse error bucket (never a message)',
|
||||
'locale language tag (e.g. en-US)',
|
||||
'is_ci whether running in CI',
|
||||
];
|
||||
|
||||
const EVENT_NAMES = ['worker_started', 'session_compressed', 'search_performed', 'error_occurred'];
|
||||
|
||||
const SOURCE_LABELS: Record<TelemetryConsentSource, string> = {
|
||||
DO_NOT_TRACK: 'DO_NOT_TRACK environment variable',
|
||||
env: 'CLAUDE_MEM_TELEMETRY environment variable',
|
||||
config: 'telemetry.json config file',
|
||||
default: 'default (no consent recorded)',
|
||||
};
|
||||
|
||||
function printTelemetryUsage(): void {
|
||||
console.error(`Usage: ${pc.bold('npx claude-mem telemetry [status|enable|disable]')}`);
|
||||
console.error(' status Show whether telemetry is on and which setting decided it (default)');
|
||||
console.error(' enable Opt in to anonymous usage analytics (interactive)');
|
||||
console.error(' disable Opt out of telemetry');
|
||||
console.error(`Docs: ${DOCS_URL}`);
|
||||
}
|
||||
|
||||
function runTelemetryStatus(): void {
|
||||
// Status is read-only: it must never create telemetry.json as a side effect.
|
||||
const config = loadTelemetryConfig();
|
||||
const { enabled, source } = explainTelemetryConsent(process.env, config);
|
||||
|
||||
const state = enabled ? pc.green('ENABLED') : pc.yellow('DISABLED');
|
||||
console.log(`${pc.bold('Telemetry:')} ${state}`);
|
||||
console.log(`${pc.bold('Decided by:')} ${SOURCE_LABELS[source]}`);
|
||||
if (config?.installId) {
|
||||
console.log(`${pc.bold('Install ID:')} ${config.installId} ${pc.dim('(random UUID, not tied to you)')}`);
|
||||
} else if (config) {
|
||||
console.log(`${pc.bold('Install ID:')} ${pc.dim('none recorded')}`);
|
||||
} else {
|
||||
console.log(`${pc.bold('Install ID:')} ${pc.dim('none (no telemetry config has been written)')}`);
|
||||
}
|
||||
console.log(`${pc.bold('Config file:')} ${getTelemetryConfigPath()}`);
|
||||
console.log(`${pc.bold('Docs:')} ${DOCS_URL}`);
|
||||
}
|
||||
|
||||
async function runTelemetryEnable(): Promise<void> {
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(pc.red('telemetry enable requires an interactive terminal (consent prompt).'));
|
||||
console.error(`Read what is collected first: ${DOCS_URL}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
p.intro(pc.bgBlue(pc.white(' claude-mem telemetry ')));
|
||||
|
||||
p.note(
|
||||
[
|
||||
'Anonymous events only, identified by a random install UUID:',
|
||||
...EVENT_NAMES.map((name) => ` ${name}`),
|
||||
'',
|
||||
'Each event carries ONLY these fields:',
|
||||
...COLLECTED_FIELDS.map((line) => ` ${line}`),
|
||||
'',
|
||||
'NEVER collected — not now, not ever:',
|
||||
' prompts or conversation content, file paths, source code,',
|
||||
' project names, git remotes, search queries, error messages,',
|
||||
' IP addresses, hardware IDs, env values, emails.',
|
||||
'',
|
||||
`Full details: ${DOCS_URL}`,
|
||||
].join('\n'),
|
||||
'What telemetry collects'
|
||||
);
|
||||
|
||||
if (process.env.DO_NOT_TRACK && process.env.DO_NOT_TRACK !== '0' && process.env.DO_NOT_TRACK !== 'false') {
|
||||
p.log.warn(
|
||||
'DO_NOT_TRACK is set in your environment. It overrides everything: telemetry will remain OFF even after enabling here.'
|
||||
);
|
||||
}
|
||||
|
||||
const shouldEnable = await p.confirm({
|
||||
message: 'Enable anonymous usage telemetry?',
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (p.isCancel(shouldEnable) || !shouldEnable) {
|
||||
p.cancel('Telemetry remains disabled. Nothing was written.');
|
||||
return;
|
||||
}
|
||||
|
||||
// getOrCreateInstallId() persists a config if none exists; reuse its ID.
|
||||
const installId = getOrCreateInstallId();
|
||||
saveTelemetryConfig({
|
||||
enabled: true,
|
||||
installId,
|
||||
decidedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
p.log.success(`Telemetry enabled. Config: ${getTelemetryConfigPath()}`);
|
||||
p.outro(`Change your mind anytime: ${pc.cyan('npx claude-mem telemetry disable')}`);
|
||||
}
|
||||
|
||||
function runTelemetryDisable(): void {
|
||||
const existing = loadTelemetryConfig();
|
||||
saveTelemetryConfig({
|
||||
enabled: false,
|
||||
installId: existing?.installId ?? '',
|
||||
decidedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log(pc.green('Telemetry disabled.'));
|
||||
console.log(`${pc.bold('Config file:')} ${getTelemetryConfigPath()}`);
|
||||
}
|
||||
|
||||
export async function runTelemetryCommand(argv: string[] = []): Promise<void> {
|
||||
const subCommand = argv[0]?.toLowerCase() ?? 'status';
|
||||
|
||||
switch (subCommand) {
|
||||
case 'status':
|
||||
runTelemetryStatus();
|
||||
break;
|
||||
case 'enable':
|
||||
await runTelemetryEnable();
|
||||
break;
|
||||
case 'disable':
|
||||
runTelemetryDisable();
|
||||
break;
|
||||
default:
|
||||
console.error(pc.red(`Unknown telemetry subcommand: ${subCommand}`));
|
||||
printTelemetryUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ ${pc.bold('Runtime Commands')} (requires Bun, delegates to installed plugin):
|
||||
${pc.cyan('npx claude-mem restart')} Restart worker service
|
||||
${pc.cyan('npx claude-mem status')} Show worker status
|
||||
${pc.cyan('npx claude-mem doctor')} Diagnose install/runtime health (bun, uv, worker)
|
||||
${pc.cyan('npx claude-mem telemetry status|enable|disable')} Manage opt-in anonymous telemetry (default off)
|
||||
${pc.cyan('npx claude-mem server start')} Start server service
|
||||
${pc.cyan('npx claude-mem server stop')} Stop server service
|
||||
${pc.cyan('npx claude-mem server restart')} Restart server service
|
||||
@@ -168,6 +169,12 @@ async function main(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'telemetry': {
|
||||
const { runTelemetryCommand } = await import('./commands/telemetry.js');
|
||||
await runTelemetryCommand(args.slice(1));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'server': {
|
||||
const { runServerCommand } = await import('./commands/server.js');
|
||||
await runServerCommand(args.slice(1));
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* `trustedDependencies` (Bun-only), so we suppress scripts at the CLI level.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
@@ -50,20 +50,45 @@ export function extractEresolveBlock(stderr: string): string {
|
||||
return stderr.slice(start).trim();
|
||||
}
|
||||
|
||||
export function runNpmStrict(cwd: string, flags: string[], isFirstRun = true): NpmResult {
|
||||
const result = spawnSync('npm', flags, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: resolveInstallTimeoutMs(isFirstRun),
|
||||
...(IS_WINDOWS ? { shell: process.env.ComSpec ?? 'cmd.exe' } : {}),
|
||||
});
|
||||
// Async (spawn, not spawnSync) so the installer's clack spinner keeps
|
||||
// animating during a multi-minute npm install — a blocked event loop freezes
|
||||
// the spinner mid-frame and the install looks stalled.
|
||||
export function runNpmStrict(cwd: string, flags: string[], isFirstRun = true): Promise<NpmResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('npm', flags, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...(IS_WINDOWS ? { shell: process.env.ComSpec ?? 'cmd.exe' } : {}),
|
||||
});
|
||||
|
||||
const timedOut = result.signal === 'SIGTERM' || (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT';
|
||||
return {
|
||||
code: typeof result.status === 'number' ? result.status : (timedOut ? 124 : 1),
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? (result.error ? String(result.error.message) : ''),
|
||||
timedOut,
|
||||
};
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
let spawnError: Error | null = null;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
}, resolveInstallTimeoutMs(isFirstRun));
|
||||
|
||||
child.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); });
|
||||
child.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||
let settled = false;
|
||||
const settle = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
code: typeof code === 'number' ? code : (timedOut ? 124 : 1),
|
||||
stdout,
|
||||
stderr: stderr || (spawnError ? String(spawnError.message) : ''),
|
||||
timedOut,
|
||||
});
|
||||
};
|
||||
|
||||
// 'close' never fires when the process fails to spawn (ENOENT), so the
|
||||
// error handler must settle too.
|
||||
child.on('error', (error) => { spawnError = error; settle(null); });
|
||||
child.on('close', (code) => { settle(code); });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
import { exec, execSync, spawnSync } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
@@ -412,11 +412,17 @@ export async function installPluginDependencies(targetDir: string, bunPath: stri
|
||||
// tree-sitter-cli postinstall downloads a Rust binary and can hang the
|
||||
// install. Bun honors trustedDependencies; npm does not. We additionally
|
||||
// pass --ignore-scripts as belt-and-suspenders and bound it with a timeout.
|
||||
execSync(`${bunCmd} install --frozen-lockfile --ignore-scripts`, {
|
||||
cwd: targetDir,
|
||||
stdio: 'pipe',
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
...(IS_WINDOWS ? { shell: process.env.ComSpec ?? 'cmd.exe' } : {}),
|
||||
// Async exec (not execSync): a blocked event loop freezes the installer's
|
||||
// clack spinner for the duration of the install, which reads as a stall.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
exec(`${bunCmd} install --frozen-lockfile --ignore-scripts`, {
|
||||
cwd: targetDir,
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
...(IS_WINDOWS ? { shell: process.env.ComSpec ?? 'cmd.exe' } : {}),
|
||||
}, (error, stdout, stderr) =>
|
||||
// exec errors don't carry stdio; attach so describeExecError can report it.
|
||||
error ? reject(Object.assign(error, { stdout, stderr })) : resolve());
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`bun install failed in ${targetDir}\n${describeExecError(error)}`);
|
||||
|
||||
123
src/services/telemetry/consent.ts
Normal file
123
src/services/telemetry/consent.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { join } from 'path';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { resolveDataDir } from '../../shared/paths.js';
|
||||
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||
|
||||
export type TelemetryConfig = {
|
||||
enabled: boolean;
|
||||
installId: string;
|
||||
decidedAt: string;
|
||||
};
|
||||
|
||||
const TELEMETRY_CONFIG_FILENAME = 'telemetry.json';
|
||||
|
||||
/**
|
||||
* DO_NOT_TRACK convention (consoledonottrack.com): the variable counts as
|
||||
* "set" when it has any non-empty value other than '0' or 'false'.
|
||||
*/
|
||||
function isDoNotTrackSet(env: NodeJS.ProcessEnv): boolean {
|
||||
const value = env.DO_NOT_TRACK;
|
||||
if (value === undefined || value === '') return false;
|
||||
return value !== '0' && value !== 'false';
|
||||
}
|
||||
|
||||
/** Which layer of the precedence chain decided the consent outcome. */
|
||||
export type TelemetryConsentSource = 'DO_NOT_TRACK' | 'env' | 'config' | 'default';
|
||||
|
||||
export type TelemetryConsentExplanation = {
|
||||
enabled: boolean;
|
||||
source: TelemetryConsentSource;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves whether telemetry is allowed AND which layer decided it.
|
||||
* Pure function — no I/O.
|
||||
*
|
||||
* Precedence (first match wins):
|
||||
* 1. DO_NOT_TRACK set (truthy) -> always off
|
||||
* 2. CLAUDE_MEM_TELEMETRY env: '0'/'false'/'off' -> off, '1'/'true'/'on' -> on
|
||||
* 3. telemetry.json config: enabled === true -> on, enabled === false -> off
|
||||
* 4. Default: off
|
||||
*/
|
||||
export function explainTelemetryConsent(
|
||||
env: NodeJS.ProcessEnv,
|
||||
config: TelemetryConfig | null
|
||||
): TelemetryConsentExplanation {
|
||||
if (isDoNotTrackSet(env)) return { enabled: false, source: 'DO_NOT_TRACK' };
|
||||
|
||||
const override = env.CLAUDE_MEM_TELEMETRY?.toLowerCase();
|
||||
if (override === '0' || override === 'false' || override === 'off') {
|
||||
return { enabled: false, source: 'env' };
|
||||
}
|
||||
if (override === '1' || override === 'true' || override === 'on') {
|
||||
return { enabled: true, source: 'env' };
|
||||
}
|
||||
|
||||
if (config?.enabled === true) return { enabled: true, source: 'config' };
|
||||
if (config?.enabled === false) return { enabled: false, source: 'config' };
|
||||
|
||||
return { enabled: false, source: 'default' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves whether telemetry is allowed. Pure function — no I/O.
|
||||
* Thin wrapper over explainTelemetryConsent; behavior is identical to the
|
||||
* original boolean resolver (a config with enabled === false and the
|
||||
* default-off case both return false).
|
||||
*/
|
||||
export function resolveTelemetryConsent(
|
||||
env: NodeJS.ProcessEnv,
|
||||
config: TelemetryConfig | null
|
||||
): boolean {
|
||||
return explainTelemetryConsent(env, config).enabled;
|
||||
}
|
||||
|
||||
/** Absolute path of telemetry.json inside the claude-mem data dir. */
|
||||
export function getTelemetryConfigPath(): string {
|
||||
return join(resolveDataDir(), TELEMETRY_CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads telemetry.json from the data dir. Returns null if the file is
|
||||
* missing, corrupt, or malformed — never throws.
|
||||
*/
|
||||
export function loadTelemetryConfig(): TelemetryConfig | null {
|
||||
try {
|
||||
const raw = readJsonSafe<Partial<TelemetryConfig> | null>(getTelemetryConfigPath(), null);
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
if (typeof raw.enabled !== 'boolean' || typeof raw.installId !== 'string') return null;
|
||||
return {
|
||||
enabled: raw.enabled,
|
||||
installId: raw.installId,
|
||||
decidedAt: typeof raw.decidedAt === 'string' ? raw.decidedAt : '',
|
||||
};
|
||||
} catch {
|
||||
// Corrupt JSON — treat as no recorded consent
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTelemetryConfig(config: TelemetryConfig): void {
|
||||
const dataDir = resolveDataDir();
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
writeFileSync(join(dataDir, TELEMETRY_CONFIG_FILENAME), JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stable anonymous install ID, generating and persisting one on
|
||||
* first use. Preserves any existing consent state; a freshly created config
|
||||
* defaults to enabled: false.
|
||||
*/
|
||||
export function getOrCreateInstallId(): string {
|
||||
const existing = loadTelemetryConfig();
|
||||
if (existing?.installId) return existing.installId;
|
||||
|
||||
const installId = randomUUID();
|
||||
saveTelemetryConfig({
|
||||
enabled: existing?.enabled ?? false,
|
||||
installId,
|
||||
decidedAt: existing?.decidedAt || new Date().toISOString(),
|
||||
});
|
||||
return installId;
|
||||
}
|
||||
51
src/services/telemetry/scrub.ts
Normal file
51
src/services/telemetry/scrub.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Whitelist scrubber for telemetry event properties.
|
||||
*
|
||||
* Only these keys may ever leave the machine. Everything else — paths,
|
||||
* project names, prompts, queries, emails, IPs, env values — is dropped
|
||||
* silently, regardless of what a call site passes in.
|
||||
*/
|
||||
export const ALLOWED_PROPERTY_KEYS: Set<string> = new Set([
|
||||
'version',
|
||||
'os',
|
||||
'arch',
|
||||
'runtime',
|
||||
'runtime_version',
|
||||
'duration_ms',
|
||||
'outcome',
|
||||
'error_category',
|
||||
'locale',
|
||||
'is_ci',
|
||||
]);
|
||||
|
||||
const MAX_STRING_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Filters properties down to whitelisted keys with primitive values only.
|
||||
* Strings are truncated to 200 chars. Objects, arrays, functions, null,
|
||||
* undefined, and non-finite numbers are dropped. Pure, never throws.
|
||||
*/
|
||||
export function scrubProperties(
|
||||
props: Record<string, unknown>
|
||||
): Record<string, string | number | boolean> {
|
||||
const scrubbed: Record<string, string | number | boolean> = {};
|
||||
try {
|
||||
if (!props || typeof props !== 'object') return scrubbed;
|
||||
for (const key of Object.keys(props)) {
|
||||
if (!ALLOWED_PROPERTY_KEYS.has(key)) continue;
|
||||
const value = props[key];
|
||||
if (typeof value === 'string') {
|
||||
scrubbed[key] = value.length > MAX_STRING_LENGTH ? value.slice(0, MAX_STRING_LENGTH) : value;
|
||||
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
scrubbed[key] = value;
|
||||
} else if (typeof value === 'boolean') {
|
||||
scrubbed[key] = value;
|
||||
}
|
||||
// Everything else (objects, arrays, functions, null, undefined,
|
||||
// NaN/Infinity, symbols, bigints) is dropped silently.
|
||||
}
|
||||
} catch {
|
||||
// Never throw from the scrubber — worst case we send fewer properties
|
||||
}
|
||||
return scrubbed;
|
||||
}
|
||||
148
src/services/telemetry/telemetry.ts
Normal file
148
src/services/telemetry/telemetry.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { PostHog } from 'posthog-node';
|
||||
import {
|
||||
resolveTelemetryConsent,
|
||||
loadTelemetryConfig,
|
||||
getOrCreateInstallId,
|
||||
} from './consent.js';
|
||||
import { scrubProperties } from './scrub.js';
|
||||
|
||||
declare const __DEFAULT_PACKAGE_VERSION__: string;
|
||||
const packageVersion =
|
||||
typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined' ? __DEFAULT_PACKAGE_VERSION__ : '0.0.0-dev';
|
||||
|
||||
/**
|
||||
* Publishable PostHog project token (phc_...). Publishable tokens are safe to
|
||||
* embed: the capture endpoints are public POST-only ingestion. Empty for now —
|
||||
* the maintainer pastes the real phc_ token before enabling ingestion.
|
||||
* `CLAUDE_MEM_TELEMETRY_KEY` always overrides this constant.
|
||||
*/
|
||||
const TELEMETRY_PUBLIC_KEY = '';
|
||||
|
||||
const DEFAULT_HOST = 'https://us.i.posthog.com';
|
||||
|
||||
let client: PostHog | null = null;
|
||||
let isShutdown = false;
|
||||
|
||||
/**
|
||||
* Consent is re-resolved at most once per TTL window so the capture path does
|
||||
* not touch the filesystem per event (telemetry.json read). A consent change
|
||||
* via the CLI is picked up by a running worker within the TTL.
|
||||
*/
|
||||
const CONSENT_CACHE_TTL_MS = 30_000;
|
||||
let consentCache: { value: boolean; expiresAt: number } | null = null;
|
||||
|
||||
function hasConsent(): boolean {
|
||||
const now = Date.now();
|
||||
if (consentCache && now < consentCache.expiresAt) {
|
||||
return consentCache.value;
|
||||
}
|
||||
const value = resolveTelemetryConsent(process.env, loadTelemetryConfig());
|
||||
consentCache = { value, expiresAt: now + CONSENT_CACHE_TTL_MS };
|
||||
return value;
|
||||
}
|
||||
|
||||
function getApiKey(): string {
|
||||
return process.env.CLAUDE_MEM_TELEMETRY_KEY || TELEMETRY_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
function getClient(): PostHog {
|
||||
if (!client) {
|
||||
client = new PostHog(getApiKey(), {
|
||||
host: process.env.CLAUDE_MEM_TELEMETRY_HOST || DEFAULT_HOST,
|
||||
flushAt: 20,
|
||||
flushInterval: 10000,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function buildBaseProperties(): Record<string, unknown> {
|
||||
return {
|
||||
version: packageVersion,
|
||||
os: process.platform,
|
||||
arch: process.arch,
|
||||
runtime: process.versions.bun ? 'bun' : 'node',
|
||||
runtime_version: process.versions.bun ?? process.versions.node,
|
||||
is_ci: Boolean(process.env.CI),
|
||||
locale: Intl.DateTimeFormat().resolvedOptions().locale,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a telemetry event. Fire-and-forget, synchronous, never throws,
|
||||
* never blocks. Ordering is deliberate:
|
||||
*
|
||||
* 1. Consent gate (DO_NOT_TRACK > env > telemetry.json > default OFF) —
|
||||
* without consent NOTHING happens, including debug printing.
|
||||
* 2. Whitelist scrub — only allowed primitive properties survive.
|
||||
* 3. Debug mode (CLAUDE_MEM_TELEMETRY_DEBUG=1) — print payload to stderr,
|
||||
* send nothing.
|
||||
* 4. No API key configured — no-op (telemetry ships dark until the
|
||||
* publishable token lands).
|
||||
* 5. posthog.capture() — SDK queues in memory and batches in background.
|
||||
*/
|
||||
export function captureEvent(event: string, props?: Record<string, unknown>): void {
|
||||
try {
|
||||
// Once shutdown has flushed the client, late events (e.g. a request that
|
||||
// raced graceful stop) are dropped rather than queued in a new client
|
||||
// that would never be flushed.
|
||||
if (isShutdown || !hasConsent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const properties: Record<string, unknown> = scrubProperties({
|
||||
...buildBaseProperties(),
|
||||
...(props ?? {}),
|
||||
});
|
||||
// Anonymous events: no person profile processing. Added AFTER scrubbing —
|
||||
// $-prefixed PostHog directives are not user data and bypass the whitelist.
|
||||
properties.$process_person_profile = false;
|
||||
|
||||
if (process.env.CLAUDE_MEM_TELEMETRY_DEBUG === '1') {
|
||||
// Direct stderr write (not console.*): debug mode is a human running the
|
||||
// worker in the foreground; repo logger standards forbid console.* in
|
||||
// background services (tests/logger-usage-standards.test.ts).
|
||||
process.stderr.write('[telemetry] ' + JSON.stringify({ event, properties }) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getApiKey()) {
|
||||
return;
|
||||
}
|
||||
|
||||
getClient().capture({
|
||||
distinctId: getOrCreateInstallId(),
|
||||
event,
|
||||
properties,
|
||||
});
|
||||
} catch {
|
||||
// Telemetry must never break the worker. Swallow everything.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush queued events on graceful shutdown. Races the SDK shutdown against a
|
||||
* 3s timeout so a slow/unreachable ingestion host can never hang worker stop.
|
||||
* Never rejects.
|
||||
*/
|
||||
export async function shutdownTelemetry(): Promise<void> {
|
||||
isShutdown = true;
|
||||
const current = client;
|
||||
client = null;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
current.shutdown(),
|
||||
new Promise<void>(resolve => {
|
||||
timer = setTimeout(resolve, 3000);
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// Never let telemetry flushing fail a shutdown.
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { configureSupervisorSignalHandlers, getSupervisor, startSupervisor } fro
|
||||
import { sanitizeEnv } from '../supervisor/env-sanitizer.js';
|
||||
|
||||
import { ensureWorkerStarted as ensureWorkerStartedShared, type WorkerStartResult } from './worker-spawner.js';
|
||||
import { captureEvent, shutdownTelemetry } from './telemetry/telemetry.js';
|
||||
|
||||
export { isPluginDisabledInClaudeSettings } from '../shared/plugin-state.js';
|
||||
import { isPluginDisabledInClaudeSettings } from '../shared/plugin-state.js';
|
||||
@@ -293,6 +294,7 @@ export class WorkerService implements WorkerRef {
|
||||
});
|
||||
|
||||
logger.info('SYSTEM', 'Worker started', { host, port, pid: process.pid });
|
||||
captureEvent('worker_started');
|
||||
|
||||
this.initializeBackground().catch((error) => {
|
||||
logger.error('SYSTEM', 'Background initialization failed', {}, error as Error);
|
||||
@@ -518,6 +520,8 @@ export class WorkerService implements WorkerRef {
|
||||
logger.info('TRANSCRIPT', 'Transcript watcher stopped');
|
||||
}
|
||||
|
||||
await shutdownTelemetry();
|
||||
|
||||
await performGracefulShutdown({
|
||||
server: this.server.getHttpServer(),
|
||||
sessionManager: this.sessionManager,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { DatabaseManager } from '../DatabaseManager.js';
|
||||
import type { SessionManager } from '../SessionManager.js';
|
||||
import type { WorkerRef, StorageResult } from './types.js';
|
||||
import { broadcastObservation, broadcastSummary } from './ObservationBroadcaster.js';
|
||||
import { captureEvent } from '../../telemetry/telemetry.js';
|
||||
|
||||
/**
|
||||
* Consecutive non-XML observer outputs tolerated before we kill and respawn the
|
||||
@@ -170,6 +171,7 @@ export async function processAgentResponse(
|
||||
});
|
||||
|
||||
session.lastSummaryStored = result.summaryId !== null;
|
||||
captureEvent('session_compressed', { outcome: 'ok' });
|
||||
|
||||
if (summary && (summary.skipped || session.lastSummaryStored)) {
|
||||
await ingestSummary({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
import { AppError } from '../../server/ErrorHandler.js';
|
||||
import { captureEvent } from '../../telemetry/telemetry.js';
|
||||
|
||||
export abstract class BaseRouteHandler {
|
||||
protected wrapHandler(
|
||||
@@ -57,6 +58,7 @@ export abstract class BaseRouteHandler {
|
||||
logger.failure('WORKER', context || 'Request failed', {}, error);
|
||||
if (!res.headersSent) {
|
||||
const statusCode = error instanceof AppError ? error.statusCode : 500;
|
||||
if (statusCode >= 500) captureEvent('error_occurred', { error_category: 'http_500' });
|
||||
const response: Record<string, unknown> = { error: error.message };
|
||||
|
||||
if (error instanceof AppError && error.code) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { countObservationsByProjects } from '../../../context/ObservationCompile
|
||||
import { SettingsDefaultsManager } from '../../../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../../../shared/paths.js';
|
||||
import type { ObservationSearchResult, SessionSummarySearchResult } from '../../../sqlite/types.js';
|
||||
import { captureEvent } from '../../../telemetry/telemetry.js';
|
||||
|
||||
const ONBOARDING_EXPLAINER_PATH: string = path.resolve(__dirname, '../skills/how-it-works/onboarding-explainer.md');
|
||||
|
||||
@@ -100,6 +101,19 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
}
|
||||
|
||||
setupRoutes(app: express.Application): void {
|
||||
// One telemetry site for every /api/search* endpoint (unified + dedicated
|
||||
// variants), so search adoption is not undercounted. /api/search/help is
|
||||
// documentation, not a search. Property is the outcome only — never query
|
||||
// text (see docs/public/telemetry.mdx).
|
||||
app.use('/api/search', (req: Request, res: Response, next: express.NextFunction) => {
|
||||
if (req.path !== '/help') {
|
||||
res.once('finish', () => {
|
||||
captureEvent('search_performed', { outcome: res.statusCode < 400 ? 'ok' : 'error' });
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/api/search', this.handleUnifiedSearch.bind(this));
|
||||
app.get('/api/timeline', this.handleUnifiedTimeline.bind(this));
|
||||
app.get('/api/decisions', this.handleDecisions.bind(this));
|
||||
|
||||
260
tests/telemetry/consent.test.ts
Normal file
260
tests/telemetry/consent.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
resolveTelemetryConsent,
|
||||
explainTelemetryConsent,
|
||||
loadTelemetryConfig,
|
||||
saveTelemetryConfig,
|
||||
getOrCreateInstallId,
|
||||
type TelemetryConfig,
|
||||
} from '../../src/services/telemetry/consent';
|
||||
|
||||
const enabledConfig: TelemetryConfig = {
|
||||
enabled: true,
|
||||
installId: '00000000-0000-4000-8000-000000000000',
|
||||
decidedAt: '2026-06-09T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const disabledConfig: TelemetryConfig = {
|
||||
enabled: false,
|
||||
installId: '00000000-0000-4000-8000-000000000001',
|
||||
decidedAt: '2026-06-09T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('resolveTelemetryConsent', () => {
|
||||
it('defaults to off with null config and empty env', () => {
|
||||
expect(resolveTelemetryConsent({}, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('DO_NOT_TRACK=1 beats an enabled config', () => {
|
||||
expect(resolveTelemetryConsent({ DO_NOT_TRACK: '1' }, enabledConfig)).toBe(false);
|
||||
});
|
||||
|
||||
it('DO_NOT_TRACK beats CLAUDE_MEM_TELEMETRY=1', () => {
|
||||
expect(
|
||||
resolveTelemetryConsent({ DO_NOT_TRACK: '1', CLAUDE_MEM_TELEMETRY: '1' }, enabledConfig)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('any non-empty DO_NOT_TRACK value other than 0/false disables', () => {
|
||||
expect(resolveTelemetryConsent({ DO_NOT_TRACK: 'true' }, enabledConfig)).toBe(false);
|
||||
expect(resolveTelemetryConsent({ DO_NOT_TRACK: 'yes' }, enabledConfig)).toBe(false);
|
||||
expect(resolveTelemetryConsent({ DO_NOT_TRACK: 'anything' }, enabledConfig)).toBe(false);
|
||||
});
|
||||
|
||||
it('DO_NOT_TRACK=0 does not disable', () => {
|
||||
expect(resolveTelemetryConsent({ DO_NOT_TRACK: '0' }, enabledConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it('DO_NOT_TRACK=false does not disable', () => {
|
||||
expect(resolveTelemetryConsent({ DO_NOT_TRACK: 'false' }, enabledConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it('empty-string DO_NOT_TRACK counts as not set', () => {
|
||||
expect(resolveTelemetryConsent({ DO_NOT_TRACK: '' }, enabledConfig)).toBe(true);
|
||||
expect(resolveTelemetryConsent({ DO_NOT_TRACK: '' }, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('CLAUDE_MEM_TELEMETRY=0 beats an enabled config', () => {
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: '0' }, enabledConfig)).toBe(false);
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'false' }, enabledConfig)).toBe(false);
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'off' }, enabledConfig)).toBe(false);
|
||||
});
|
||||
|
||||
it('CLAUDE_MEM_TELEMETRY=1 enables without any config', () => {
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: '1' }, null)).toBe(true);
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'true' }, null)).toBe(true);
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'on' }, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('CLAUDE_MEM_TELEMETRY=1 beats a disabled config', () => {
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: '1' }, disabledConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it('CLAUDE_MEM_TELEMETRY values are case-insensitive', () => {
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'OFF' }, enabledConfig)).toBe(false);
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'ON' }, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('unrecognized CLAUDE_MEM_TELEMETRY values fall through to config', () => {
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'maybe' }, enabledConfig)).toBe(true);
|
||||
expect(resolveTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'maybe' }, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('config enabled=true enables with empty env', () => {
|
||||
expect(resolveTelemetryConsent({}, enabledConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it('config enabled=false stays off', () => {
|
||||
expect(resolveTelemetryConsent({}, disabledConfig)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explainTelemetryConsent', () => {
|
||||
it('attributes DO_NOT_TRACK as the deciding layer', () => {
|
||||
expect(explainTelemetryConsent({ DO_NOT_TRACK: '1' }, enabledConfig)).toEqual({
|
||||
enabled: false,
|
||||
source: 'DO_NOT_TRACK',
|
||||
});
|
||||
});
|
||||
|
||||
it('DO_NOT_TRACK wins over an enabling env override', () => {
|
||||
expect(
|
||||
explainTelemetryConsent({ DO_NOT_TRACK: '1', CLAUDE_MEM_TELEMETRY: '1' }, enabledConfig)
|
||||
).toEqual({ enabled: false, source: 'DO_NOT_TRACK' });
|
||||
});
|
||||
|
||||
it('attributes CLAUDE_MEM_TELEMETRY to the env layer (off)', () => {
|
||||
expect(explainTelemetryConsent({ CLAUDE_MEM_TELEMETRY: '0' }, enabledConfig)).toEqual({
|
||||
enabled: false,
|
||||
source: 'env',
|
||||
});
|
||||
});
|
||||
|
||||
it('attributes CLAUDE_MEM_TELEMETRY to the env layer (on)', () => {
|
||||
expect(explainTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'on' }, null)).toEqual({
|
||||
enabled: true,
|
||||
source: 'env',
|
||||
});
|
||||
});
|
||||
|
||||
it('attributes a config decision to the config layer', () => {
|
||||
expect(explainTelemetryConsent({}, enabledConfig)).toEqual({
|
||||
enabled: true,
|
||||
source: 'config',
|
||||
});
|
||||
expect(explainTelemetryConsent({}, disabledConfig)).toEqual({
|
||||
enabled: false,
|
||||
source: 'config',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to default-off with no env and no config', () => {
|
||||
expect(explainTelemetryConsent({}, null)).toEqual({ enabled: false, source: 'default' });
|
||||
});
|
||||
|
||||
it('unrecognized env values fall through to config/default', () => {
|
||||
expect(explainTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'maybe' }, enabledConfig)).toEqual({
|
||||
enabled: true,
|
||||
source: 'config',
|
||||
});
|
||||
expect(explainTelemetryConsent({ CLAUDE_MEM_TELEMETRY: 'maybe' }, null)).toEqual({
|
||||
enabled: false,
|
||||
source: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('agrees with resolveTelemetryConsent for every layer', () => {
|
||||
const cases: Array<[NodeJS.ProcessEnv, TelemetryConfig | null]> = [
|
||||
[{ DO_NOT_TRACK: '1' }, enabledConfig],
|
||||
[{ CLAUDE_MEM_TELEMETRY: '0' }, enabledConfig],
|
||||
[{ CLAUDE_MEM_TELEMETRY: '1' }, disabledConfig],
|
||||
[{}, enabledConfig],
|
||||
[{}, disabledConfig],
|
||||
[{}, null],
|
||||
];
|
||||
for (const [env, config] of cases) {
|
||||
expect(explainTelemetryConsent(env, config).enabled).toBe(
|
||||
resolveTelemetryConsent(env, config)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry config persistence', () => {
|
||||
let tempDir: string;
|
||||
let previousDataDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `telemetry-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
previousDataDir = process.env.CLAUDE_MEM_DATA_DIR;
|
||||
process.env.CLAUDE_MEM_DATA_DIR = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousDataDir === undefined) {
|
||||
delete process.env.CLAUDE_MEM_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_MEM_DATA_DIR = previousDataDir;
|
||||
}
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('loadTelemetryConfig', () => {
|
||||
it('returns null when telemetry.json does not exist', () => {
|
||||
expect(loadTelemetryConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for corrupt JSON without throwing', () => {
|
||||
writeFileSync(join(tempDir, 'telemetry.json'), 'not valid json {{{');
|
||||
|
||||
expect(loadTelemetryConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed shapes', () => {
|
||||
writeFileSync(join(tempDir, 'telemetry.json'), JSON.stringify({ enabled: 'yes' }));
|
||||
expect(loadTelemetryConfig()).toBeNull();
|
||||
|
||||
writeFileSync(join(tempDir, 'telemetry.json'), JSON.stringify([1, 2, 3]));
|
||||
expect(loadTelemetryConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips a saved config', () => {
|
||||
saveTelemetryConfig(enabledConfig);
|
||||
|
||||
expect(loadTelemetryConfig()).toEqual(enabledConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveTelemetryConfig', () => {
|
||||
it('creates the data dir if missing', () => {
|
||||
const nestedDir = join(tempDir, 'nested', 'data-dir');
|
||||
process.env.CLAUDE_MEM_DATA_DIR = nestedDir;
|
||||
|
||||
saveTelemetryConfig(disabledConfig);
|
||||
|
||||
expect(existsSync(join(nestedDir, 'telemetry.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('writes pretty-printed JSON', () => {
|
||||
saveTelemetryConfig(disabledConfig);
|
||||
|
||||
const raw = readFileSync(join(tempDir, 'telemetry.json'), 'utf-8');
|
||||
expect(raw).toContain('\n "enabled": false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrCreateInstallId', () => {
|
||||
it('generates a UUID and persists it with enabled defaulting to false', () => {
|
||||
const id = getOrCreateInstallId();
|
||||
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
const config = loadTelemetryConfig();
|
||||
expect(config?.installId).toBe(id);
|
||||
expect(config?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the existing install ID on subsequent calls', () => {
|
||||
const first = getOrCreateInstallId();
|
||||
const second = getOrCreateInstallId();
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('preserves enabled state from an existing config', () => {
|
||||
saveTelemetryConfig(enabledConfig);
|
||||
|
||||
const id = getOrCreateInstallId();
|
||||
|
||||
expect(id).toBe(enabledConfig.installId);
|
||||
expect(loadTelemetryConfig()?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
153
tests/telemetry/scrub.test.ts
Normal file
153
tests/telemetry/scrub.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { scrubProperties, ALLOWED_PROPERTY_KEYS } from '../../src/services/telemetry/scrub';
|
||||
|
||||
describe('scrubProperties', () => {
|
||||
it('keeps whitelisted keys with primitive values', () => {
|
||||
const result = scrubProperties({
|
||||
version: '13.4.2',
|
||||
os: 'darwin',
|
||||
arch: 'arm64',
|
||||
runtime: 'bun',
|
||||
runtime_version: '1.2.0',
|
||||
duration_ms: 1234,
|
||||
outcome: 'success',
|
||||
error_category: 'timeout',
|
||||
locale: 'en-US',
|
||||
is_ci: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
version: '13.4.2',
|
||||
os: 'darwin',
|
||||
arch: 'arm64',
|
||||
runtime: 'bun',
|
||||
runtime_version: '1.2.0',
|
||||
duration_ms: 1234,
|
||||
outcome: 'success',
|
||||
error_category: 'timeout',
|
||||
locale: 'en-US',
|
||||
is_ci: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unknown keys silently', () => {
|
||||
const result = scrubProperties({
|
||||
version: '1.0.0',
|
||||
session_id: 'abc-123',
|
||||
random_key: 'value',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ version: '1.0.0' });
|
||||
});
|
||||
|
||||
it('drops sensitive-looking keys even if present', () => {
|
||||
const result = scrubProperties({
|
||||
path: '/Users/alice/secret-project/index.ts',
|
||||
cwd: '/Users/alice/secret-project',
|
||||
prompt: 'fix my auth bug',
|
||||
query: 'password reset flow',
|
||||
project_name: 'secret-project',
|
||||
email: 'alice@example.com',
|
||||
ip: '203.0.113.7',
|
||||
outcome: 'success',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: 'success' });
|
||||
expect(Object.keys(result)).not.toContain('path');
|
||||
expect(Object.keys(result)).not.toContain('cwd');
|
||||
expect(Object.keys(result)).not.toContain('prompt');
|
||||
expect(Object.keys(result)).not.toContain('query');
|
||||
expect(Object.keys(result)).not.toContain('project_name');
|
||||
expect(Object.keys(result)).not.toContain('email');
|
||||
expect(Object.keys(result)).not.toContain('ip');
|
||||
});
|
||||
|
||||
it('whitelist never contains sensitive keys', () => {
|
||||
for (const key of ['path', 'cwd', 'prompt', 'query', 'project_name', 'email', 'ip']) {
|
||||
expect(ALLOWED_PROPERTY_KEYS.has(key)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops nested objects on whitelisted keys', () => {
|
||||
const result = scrubProperties({
|
||||
outcome: { status: 'ok', detail: '/some/path' },
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ version: '1.0.0' });
|
||||
});
|
||||
|
||||
it('drops arrays on whitelisted keys', () => {
|
||||
const result = scrubProperties({
|
||||
outcome: ['a', 'b'],
|
||||
duration_ms: 5,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ duration_ms: 5 });
|
||||
});
|
||||
|
||||
it('drops functions on whitelisted keys', () => {
|
||||
const result = scrubProperties({
|
||||
outcome: () => 'success',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ version: '1.0.0' });
|
||||
});
|
||||
|
||||
it('drops null and undefined values', () => {
|
||||
const result = scrubProperties({
|
||||
outcome: null,
|
||||
error_category: undefined,
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ version: '1.0.0' });
|
||||
});
|
||||
|
||||
it('drops NaN and Infinity', () => {
|
||||
const result = scrubProperties({
|
||||
duration_ms: NaN,
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ version: '1.0.0' });
|
||||
|
||||
expect(scrubProperties({ duration_ms: Infinity })).toEqual({});
|
||||
});
|
||||
|
||||
it('truncates strings longer than 200 characters', () => {
|
||||
const long = 'x'.repeat(500);
|
||||
|
||||
const result = scrubProperties({ outcome: long });
|
||||
|
||||
expect(result.outcome).toBe('x'.repeat(200));
|
||||
expect((result.outcome as string).length).toBe(200);
|
||||
});
|
||||
|
||||
it('leaves strings of exactly 200 characters untouched', () => {
|
||||
const exact = 'y'.repeat(200);
|
||||
|
||||
const result = scrubProperties({ outcome: exact });
|
||||
|
||||
expect(result.outcome).toBe(exact);
|
||||
});
|
||||
|
||||
it('returns an empty object for empty input', () => {
|
||||
expect(scrubProperties({})).toEqual({});
|
||||
});
|
||||
|
||||
it('never throws on hostile input', () => {
|
||||
expect(scrubProperties(null as unknown as Record<string, unknown>)).toEqual({});
|
||||
expect(scrubProperties(undefined as unknown as Record<string, unknown>)).toEqual({});
|
||||
|
||||
const hostile: Record<string, unknown> = {};
|
||||
Object.defineProperty(hostile, 'outcome', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error('gotcha');
|
||||
},
|
||||
});
|
||||
expect(scrubProperties(hostile)).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user