From 6233f6c11d0b25cccef92dd58d12da879ea9d1ea Mon Sep 17 00:00:00 2001 From: melkeydev Date: Thu, 9 Apr 2026 13:58:03 -0700 Subject: [PATCH] removing storing prompt telemetry --- README.md | 12 +-- hooks/setup-telemetry.mjs | 23 +---- hooks/src/setup-telemetry.mts | 25 +---- hooks/src/telemetry.mts | 9 +- hooks/src/user-prompt-submit-telemetry.mts | 114 ++++----------------- hooks/telemetry.mjs | 6 +- hooks/user-prompt-submit-telemetry.mjs | 67 +----------- tests/telemetry.test.ts | 47 ++++++++- 8 files changed, 90 insertions(+), 213 deletions(-) diff --git a/README.md b/README.md index a518c87..768537f 100644 --- a/README.md +++ b/README.md @@ -111,16 +111,17 @@ After installing, skills and context are injected automatically. You can also in ## Telemetry -The plugin has two separate telemetry controls: +The plugin has one active telemetry control: -- `~/.claude/vercel-plugin-telemetry-preference` controls prompt text only. - `VERCEL_PLUGIN_TELEMETRY=off` disables all telemetry. Behavior: -- `echo 'enabled' > ~/.claude/vercel-plugin-telemetry-preference` keeps default base telemetry on and also allows prompt text telemetry. -- `echo 'disabled' > ~/.claude/vercel-plugin-telemetry-preference` keeps prompt text off, but base telemetry remains on by default. -- `VERCEL_PLUGIN_TELEMETRY=off` disables all telemetry, including prompt text, session metadata, tool names, and skill-injection telemetry. +- Prompt text telemetry is currently disabled in the plugin regardless of any preference file. +- By default, the plugin sends base telemetry only: session metadata, tool names, and skill-injection telemetry. +- Base telemetry includes `session:device_id`, a stable anonymous UUID stored at `~/.claude/vercel-plugin-device-id`. +- `session:device_id` is used to measure things like daily active users. It is not tied to a Vercel account, login, email address, prompt text, file contents, or bash commands. +- `VERCEL_PLUGIN_TELEMETRY=off` disables all telemetry, including `session:device_id`, session metadata, tool names, and skill-injection telemetry. Where to set `VERCEL_PLUGIN_TELEMETRY=off`: @@ -130,7 +131,6 @@ Where to set `VERCEL_PLUGIN_TELEMETRY=off`: Examples: ```bash -echo 'disabled' > ~/.claude/vercel-plugin-telemetry-preference export VERCEL_PLUGIN_TELEMETRY=off ``` diff --git a/hooks/setup-telemetry.mjs b/hooks/setup-telemetry.mjs index 1ba166d..79df3ac 100755 --- a/hooks/setup-telemetry.mjs +++ b/hooks/setup-telemetry.mjs @@ -1,39 +1,20 @@ #!/usr/bin/env node // hooks/src/setup-telemetry.mts -import { readFileSync } from "fs"; -import { homedir } from "os"; -import { join } from "path"; import { getTelemetryOverride } from "./telemetry.mjs"; -var PREF_PATH = join(homedir(), ".claude", "vercel-plugin-telemetry-preference"); function main() { const telemetryOverride = getTelemetryOverride(); if (telemetryOverride === "off") { process.stdout.write( - "Telemetry is fully disabled via VERCEL_PLUGIN_TELEMETRY=off.\nRemove or change that env var before changing the prompt-sharing preference.\n" - ); - process.exit(0); - } - let existing = null; - try { - existing = readFileSync(PREF_PATH, "utf-8").trim(); - } catch { - } - if (existing === "enabled" || existing === "disabled" || existing === "asked") { - process.stdout.write( - `Prompt telemetry is currently ${existing}. This setting controls prompt text sharing only. -To change prompt sharing, edit ~/.claude/vercel-plugin-telemetry-preference. To disable all telemetry, set VERCEL_PLUGIN_TELEMETRY=off in the environment that launches your agent. -` + "Telemetry is fully disabled via VERCEL_PLUGIN_TELEMETRY=off.\nPrompt text telemetry is currently disabled in the plugin, so there is no separate prompt-sharing preference to change.\n" ); process.exit(0); } process.stdout.write( [ "The Vercel plugin collects anonymous usage data such as skill injection patterns and tools used by default.", - "Ask the user if they would also like to share their prompt text to help improve skill matching. This choice only controls prompt telemetry.", + "Prompt text telemetry is currently disabled in the plugin, so there is no consent prompt to show.", "To disable all telemetry, set VERCEL_PLUGIN_TELEMETRY=off in the environment that launches your agent.", - "If they agree, run `echo 'enabled' > ~/.claude/vercel-plugin-telemetry-preference`.", - "If they decline, run `echo 'disabled' > ~/.claude/vercel-plugin-telemetry-preference`.", "" ].join("\n") ); diff --git a/hooks/src/setup-telemetry.mts b/hooks/src/setup-telemetry.mts index 033fd7e..725d0bc 100644 --- a/hooks/src/setup-telemetry.mts +++ b/hooks/src/setup-telemetry.mts @@ -1,31 +1,12 @@ #!/usr/bin/env node -import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { join, dirname } from "node:path"; import { getTelemetryOverride } from "./telemetry.mjs"; -const PREF_PATH = join(homedir(), ".claude", "vercel-plugin-telemetry-preference"); - function main(): void { const telemetryOverride = getTelemetryOverride(); if (telemetryOverride === "off") { process.stdout.write( - "Telemetry is fully disabled via VERCEL_PLUGIN_TELEMETRY=off.\nRemove or change that env var before changing the prompt-sharing preference.\n", - ); - process.exit(0); - } - - let existing: string | null = null; - try { - existing = readFileSync(PREF_PATH, "utf-8").trim(); - } catch { - // File doesn't exist - } - - if (existing === "enabled" || existing === "disabled" || existing === "asked") { - process.stdout.write( - `Prompt telemetry is currently ${existing}. This setting controls prompt text sharing only.\nTo change prompt sharing, edit ~/.claude/vercel-plugin-telemetry-preference. To disable all telemetry, set VERCEL_PLUGIN_TELEMETRY=off in the environment that launches your agent.\n`, + "Telemetry is fully disabled via VERCEL_PLUGIN_TELEMETRY=off.\nPrompt text telemetry is currently disabled in the plugin, so there is no separate prompt-sharing preference to change.\n", ); process.exit(0); } @@ -33,10 +14,8 @@ function main(): void { process.stdout.write( [ "The Vercel plugin collects anonymous usage data such as skill injection patterns and tools used by default.", - "Ask the user if they would also like to share their prompt text to help improve skill matching. This choice only controls prompt telemetry.", + "Prompt text telemetry is currently disabled in the plugin, so there is no consent prompt to show.", "To disable all telemetry, set VERCEL_PLUGIN_TELEMETRY=off in the environment that launches your agent.", - "If they agree, run `echo 'enabled' > ~/.claude/vercel-plugin-telemetry-preference`.", - "If they decline, run `echo 'disabled' > ~/.claude/vercel-plugin-telemetry-preference`.", "", ].join("\n"), ); diff --git a/hooks/src/telemetry.mts b/hooks/src/telemetry.mts index 50e375b..9c0684c 100644 --- a/hooks/src/telemetry.mts +++ b/hooks/src/telemetry.mts @@ -10,6 +10,7 @@ const BRIDGE_ENDPOINT = "https://telemetry.vercel.com/api/vercel-plugin/v1/event const FLUSH_TIMEOUT_MS = 3_000; const DEVICE_ID_PATH = join(homedir(), ".claude", "vercel-plugin-device-id"); +const DISABLED_CONTENT_KEYS = new Set(["prompt:text"]); export interface TelemetryEvent { id: string; @@ -157,10 +158,11 @@ export async function trackBaseEvents( } // --------------------------------------------------------------------------- -// Opt-in telemetry (raw prompt content) +// Opt-in telemetry (raw prompt content, currently disabled) // --------------------------------------------------------------------------- export async function trackContentEvent(sessionId: string, key: string, value: string): Promise { + if (DISABLED_CONTENT_KEYS.has(key)) return; if (!isContentTelemetryEnabled()) return; const event: TelemetryEvent = { @@ -179,8 +181,11 @@ export async function trackContentEvents( ): Promise { if (!isContentTelemetryEnabled() || entries.length === 0) return; + const filteredEntries = entries.filter((entry) => !DISABLED_CONTENT_KEYS.has(entry.key)); + if (filteredEntries.length === 0) return; + const now = Date.now(); - const events: TelemetryEvent[] = entries.map((entry) => ({ + const events: TelemetryEvent[] = filteredEntries.map((entry) => ({ id: randomUUID(), event_time: now, key: entry.key, diff --git a/hooks/src/user-prompt-submit-telemetry.mts b/hooks/src/user-prompt-submit-telemetry.mts index 50944aa..9776f97 100644 --- a/hooks/src/user-prompt-submit-telemetry.mts +++ b/hooks/src/user-prompt-submit-telemetry.mts @@ -1,34 +1,15 @@ #!/usr/bin/env node /** - * UserPromptSubmit hook: prompt telemetry opt-in + prompt text tracking. + * UserPromptSubmit hook: prompt telemetry is currently disabled. * - * Fires on every user message. Two responsibilities: - * - * 1. Track prompt:text telemetry (awaited) for every prompt >= 10 chars - * when prompt telemetry is enabled. This runs independently of skill - * matching so prompts are never silently dropped. - * - * 2. On the first message of a session where the user hasn't recorded a - * prompt telemetry preference, return additionalContext asking the model - * to prompt the user for opt-in. Writes "asked" immediately so the user - * is never re-prompted. session-end-cleanup converts "asked" → "disabled". - * - * Note: Base telemetry is enabled by default, but users can disable all - * telemetry with VERCEL_PLUGIN_TELEMETRY=off. This hook only gates prompt - * text collection when telemetry is otherwise enabled. + * Prompt text collection is intentionally disabled regardless of preference. + * The hook remains in place as a no-op for compatibility with hooks.json. * * Input: JSON on stdin with { session_id, prompt } - * Output: JSON on stdout with { hookSpecificOutput: { hookEventName, additionalContext } } or {} + * Output: JSON on stdout with {} */ -import type { SyncHookJSONOutput } from "@anthropic-ai/claude-agent-sdk"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join, dirname } from "node:path"; -import { getTelemetryOverride, isContentTelemetryEnabled, trackContentEvents } from "./telemetry.mjs"; - -const PREF_PATH = join(homedir(), ".claude", "vercel-plugin-telemetry-preference"); -const MIN_PROMPT_LENGTH = 10; +import { readFileSync } from "node:fs"; function parseStdin(): Record | null { try { @@ -52,78 +33,19 @@ async function main(): Promise { const input = parseStdin(); const sessionId = input ? resolveSessionId(input) : ""; const prompt = input ? resolvePrompt(input) : ""; - const telemetryOverride = getTelemetryOverride(); - - // Prompt text tracking — opt-in only - if (isContentTelemetryEnabled() && sessionId && prompt.length >= MIN_PROMPT_LENGTH) { - await trackContentEvents(sessionId, [ - { key: "prompt:text", value: prompt }, - ]).catch(() => {}); - } - - // A global kill switch means the user has already disabled telemetry. - if (telemetryOverride === "off") { - process.stdout.write("{}"); - process.exit(0); - } - - // Already opted in, out, or asked — nothing more to do - try { - const pref = readFileSync(PREF_PATH, "utf-8").trim(); - if (pref === "enabled" || pref === "disabled" || pref === "asked") { - process.stdout.write("{}"); - process.exit(0); - } - } catch { - // File doesn't exist — user hasn't decided yet - } - - // Once-per-session guard: don't ask again after the first message - if (sessionId) { - const flagPath = join(tmpdir(), `vercel-plugin-telemetry-asked-${sessionId}`); - if (existsSync(flagPath)) { - process.stdout.write("{}"); - process.exit(0); - } - try { - mkdirSync(dirname(flagPath), { recursive: true }); - writeFileSync(flagPath, "1", { flag: "wx" }); - } catch { - // Race or permission issue — skip this time - process.stdout.write("{}"); - process.exit(0); - } - } - - // Write "asked" immediately so we never re-prompt across sessions - try { - mkdirSync(dirname(PREF_PATH), { recursive: true }); - writeFileSync(PREF_PATH, "asked"); - } catch { - // If we can't write, fall through — tmpdir flag still guards this session - } - - const message = [ - "After responding to the user's message, use the AskUserQuestion tool to ask about telemetry.", - "Use this exact question configuration:", - '- question: "The Vercel plugin collects anonymous usage data such as skill injection patterns and tools used by default. Would you like to also share your prompt text to help improve skill matching? This choice only controls prompt telemetry."', - '- header: "Telemetry"', - '- options: [{"label": "Share prompts", "description": "Share prompt text in addition to default anonymous usage data"}, {"label": "No thanks", "description": "Keep prompt text private. This only disables prompt telemetry"}]', - "- multiSelect: false", - "", - "After the user responds:", - "- If they chose \"Share prompts\", run: `echo 'enabled' > ~/.claude/vercel-plugin-telemetry-preference`", - "- If they chose \"No thanks\" or anything else, run: `echo 'disabled' > ~/.claude/vercel-plugin-telemetry-preference`", - ].join("\n"); - - const output: SyncHookJSONOutput = { - hookSpecificOutput: { - hookEventName: "UserPromptSubmit" as const, - additionalContext: message, - }, - }; - - process.stdout.write(JSON.stringify(output)); + void sessionId; + void prompt; + + // Prompt text tracking is intentionally disabled for now, regardless of + // the user's preference file or VERCEL_PLUGIN_TELEMETRY value. + // + // if (isContentTelemetryEnabled() && sessionId && prompt.length >= MIN_PROMPT_LENGTH) { + // await trackContentEvents(sessionId, [ + // { key: "prompt:text", value: prompt }, + // ]).catch(() => {}); + // } + + process.stdout.write("{}"); process.exit(0); } diff --git a/hooks/telemetry.mjs b/hooks/telemetry.mjs index 9da5742..374dea1 100644 --- a/hooks/telemetry.mjs +++ b/hooks/telemetry.mjs @@ -8,6 +8,7 @@ var TRUNCATION_SUFFIX = "[TRUNCATED]"; var BRIDGE_ENDPOINT = "https://telemetry.vercel.com/api/vercel-plugin/v1/events"; var FLUSH_TIMEOUT_MS = 3e3; var DEVICE_ID_PATH = join(homedir(), ".claude", "vercel-plugin-device-id"); +var DISABLED_CONTENT_KEYS = /* @__PURE__ */ new Set(["prompt:text"]); function truncateValue(value) { if (Buffer.byteLength(value, "utf-8") <= MAX_VALUE_BYTES) { return value; @@ -93,6 +94,7 @@ async function trackBaseEvents(sessionId, entries) { await send(sessionId, events); } async function trackContentEvent(sessionId, key, value) { + if (DISABLED_CONTENT_KEYS.has(key)) return; if (!isContentTelemetryEnabled()) return; const event = { id: randomUUID(), @@ -104,8 +106,10 @@ async function trackContentEvent(sessionId, key, value) { } async function trackContentEvents(sessionId, entries) { if (!isContentTelemetryEnabled() || entries.length === 0) return; + const filteredEntries = entries.filter((entry) => !DISABLED_CONTENT_KEYS.has(entry.key)); + if (filteredEntries.length === 0) return; const now = Date.now(); - const events = entries.map((entry) => ({ + const events = filteredEntries.map((entry) => ({ id: randomUUID(), event_time: now, key: entry.key, diff --git a/hooks/user-prompt-submit-telemetry.mjs b/hooks/user-prompt-submit-telemetry.mjs index 351c1c0..2a187b0 100755 --- a/hooks/user-prompt-submit-telemetry.mjs +++ b/hooks/user-prompt-submit-telemetry.mjs @@ -1,12 +1,7 @@ #!/usr/bin/env node // hooks/src/user-prompt-submit-telemetry.mts -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; -import { homedir, tmpdir } from "os"; -import { join, dirname } from "path"; -import { getTelemetryOverride, isContentTelemetryEnabled, trackContentEvents } from "./telemetry.mjs"; -var PREF_PATH = join(homedir(), ".claude", "vercel-plugin-telemetry-preference"); -var MIN_PROMPT_LENGTH = 10; +import { readFileSync } from "fs"; function parseStdin() { try { const raw = readFileSync(0, "utf-8").trim(); @@ -26,63 +21,9 @@ async function main() { const input = parseStdin(); const sessionId = input ? resolveSessionId(input) : ""; const prompt = input ? resolvePrompt(input) : ""; - const telemetryOverride = getTelemetryOverride(); - if (isContentTelemetryEnabled() && sessionId && prompt.length >= MIN_PROMPT_LENGTH) { - await trackContentEvents(sessionId, [ - { key: "prompt:text", value: prompt } - ]).catch(() => { - }); - } - if (telemetryOverride === "off") { - process.stdout.write("{}"); - process.exit(0); - } - try { - const pref = readFileSync(PREF_PATH, "utf-8").trim(); - if (pref === "enabled" || pref === "disabled" || pref === "asked") { - process.stdout.write("{}"); - process.exit(0); - } - } catch { - } - if (sessionId) { - const flagPath = join(tmpdir(), `vercel-plugin-telemetry-asked-${sessionId}`); - if (existsSync(flagPath)) { - process.stdout.write("{}"); - process.exit(0); - } - try { - mkdirSync(dirname(flagPath), { recursive: true }); - writeFileSync(flagPath, "1", { flag: "wx" }); - } catch { - process.stdout.write("{}"); - process.exit(0); - } - } - try { - mkdirSync(dirname(PREF_PATH), { recursive: true }); - writeFileSync(PREF_PATH, "asked"); - } catch { - } - const message = [ - "After responding to the user's message, use the AskUserQuestion tool to ask about telemetry.", - "Use this exact question configuration:", - '- question: "The Vercel plugin collects anonymous usage data such as skill injection patterns and tools used by default. Would you like to also share your prompt text to help improve skill matching? This choice only controls prompt telemetry."', - '- header: "Telemetry"', - '- options: [{"label": "Share prompts", "description": "Share prompt text in addition to default anonymous usage data"}, {"label": "No thanks", "description": "Keep prompt text private. This only disables prompt telemetry"}]', - "- multiSelect: false", - "", - "After the user responds:", - "- If they chose \"Share prompts\", run: `echo 'enabled' > ~/.claude/vercel-plugin-telemetry-preference`", - "- If they chose \"No thanks\" or anything else, run: `echo 'disabled' > ~/.claude/vercel-plugin-telemetry-preference`" - ].join("\n"); - const output = { - hookSpecificOutput: { - hookEventName: "UserPromptSubmit", - additionalContext: message - } - }; - process.stdout.write(JSON.stringify(output)); + void sessionId; + void prompt; + process.stdout.write("{}"); process.exit(0); } main(); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index c904c9c..4215af1 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -100,6 +100,35 @@ async function runPromptHook(env: Record): Promise<{ return { code, stdout, stderr }; } +async function runPromptHookWithPreference(preference: "enabled" | "disabled"): Promise<{ code: number; stdout: string; stderr: string }> { + const mergedEnv: Record = { + ...(process.env as Record), + HOME: tempHome, + }; + + const prefDir = join(tempHome, ".claude"); + mkdirSync(prefDir, { recursive: true }); + writeFileSync(join(prefDir, "vercel-plugin-telemetry-preference"), preference, "utf-8"); + + const proc = Bun.spawn([NODE_BIN, USER_PROMPT_HOOK], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: mergedEnv, + }); + + proc.stdin.write(JSON.stringify({ + session_id: "telemetry-session", + prompt: "show me the telemetry behavior", + })); + proc.stdin.end(); + + const code = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + return { code, stdout, stderr }; +} + beforeEach(() => { tempHome = mkdtempSync(join(tmpdir(), "telemetry-home-")); }); @@ -123,6 +152,13 @@ describe("telemetry controls", () => { expect(result.calls).toBe(1); }); + test("enabled preference still does not send prompt text telemetry", async () => { + const result = await runTelemetryProbe({ preference: "enabled" }); + expect(result.baseEnabled).toBe(true); + expect(result.contentEnabled).toBe(true); + expect(result.calls).toBe(1); + }); + test("prompt hook does not ask for telemetry when VERCEL_PLUGIN_TELEMETRY=off", async () => { const prefPath = join(tempHome, ".claude", "vercel-plugin-telemetry-preference"); const result = await runPromptHook({ @@ -135,11 +171,20 @@ describe("telemetry controls", () => { expect(existsSync(prefPath)).toBe(false); }); + test("prompt hook is a no-op even when prompt preference is enabled", async () => { + const result = await runPromptHookWithPreference("enabled"); + + expect(result.code).toBe(0); + expect(result.stdout).toBe("{}"); + }); + test("compiled hooks do not emit bash command telemetry keys", () => { const pretoolHook = readFileSync(join(ROOT, "hooks", "pretooluse-skill-inject.mjs"), "utf-8"); const posttoolHook = readFileSync(join(ROOT, "hooks", "posttooluse-telemetry.mjs"), "utf-8"); + const promptHook = readFileSync(join(ROOT, "hooks", "user-prompt-submit-telemetry.mjs"), "utf-8"); expect(pretoolHook.includes("tool_call:command")).toBe(false); expect(posttoolHook.includes("bash:command")).toBe(false); + expect(promptHook.includes("prompt:text")).toBe(false); }); });