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:
Alex Newman
2026-06-09 17:16:49 -07:00
committed by GitHub
parent aedfe5b6d9
commit b0171ed63d
20 changed files with 1516 additions and 287 deletions

View File

@@ -83,6 +83,7 @@
"configuration/litellm-gateway",
"configuration/custom-anthropic-backends",
"modes",
"telemetry",
"development",
"troubleshooting",
"platform-integration",

108
docs/public/telemetry.mdx Normal file
View 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).

View File

@@ -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",

View 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

View File

@@ -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 {

View 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);
}
}

View File

@@ -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));

View File

@@ -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); });
});
}

View File

@@ -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)}`);

View 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;
}

View 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;
}

View 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);
}
}

View File

@@ -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,

View File

@@ -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({

View File

@@ -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) {

View File

@@ -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));

View 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);
});
});
});

View 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({});
});
});