From a9f4f607550a9261319b272ae120058cfb346389 Mon Sep 17 00:00:00 2001 From: Grappeggia Date: Tue, 7 Apr 2026 15:44:37 -0700 Subject: [PATCH 1/9] refresh linked vercel project telemetry in long sessions Resolve project and org ids from local .vercel metadata and refresh them hourly on prompts so dashboard attribution stays accurate without extra API calls. --- hooks/hook-env.mjs | 140 +++++++++++++- hooks/session-start-profiler.mjs | 28 ++- hooks/src/hook-env.mts | 203 ++++++++++++++++++++- hooks/src/session-start-profiler.mts | 30 ++- hooks/src/user-prompt-submit-telemetry.mts | 64 ++++++- hooks/user-prompt-submit-telemetry.mjs | 43 ++++- tests/session-start-profiler.test.ts | 111 ++++++++++- tests/telemetry.test.ts | 15 ++ 8 files changed, 621 insertions(+), 13 deletions(-) diff --git a/hooks/hook-env.mjs b/hooks/hook-env.mjs index 8e882ea..4af9bf7 100644 --- a/hooks/hook-env.mjs +++ b/hooks/hook-env.mjs @@ -3,6 +3,7 @@ import { createHash, randomUUID } from "crypto"; import { appendFileSync, closeSync, + existsSync, mkdirSync, openSync, readFileSync, @@ -11,7 +12,7 @@ import { writeFileSync } from "fs"; import { homedir, tmpdir } from "os"; -import { dirname, join, resolve, sep } from "path"; +import { dirname, join, relative, resolve, sep } from "path"; import { fileURLToPath } from "url"; import { createLogger, logCaughtError } from "./logger.mjs"; var log = createLogger(); @@ -197,21 +198,156 @@ function safeReadJson(path) { return null; } } +var SESSION_VERCEL_PROJECT_LINK_KIND = "vercel-project-link"; +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function readJsonIfExists(path) { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch { + return null; + } +} +function asNonEmptyString(value) { + return typeof value === "string" && value.trim() !== "" ? value : null; +} +function normalizeRepoPath(pathValue) { + const normalized = pathValue.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, ""); + return normalized === "" ? "." : normalized; +} +function pathDepth(pathValue) { + return pathValue === "." ? 0 : pathValue.split("/").length; +} +function matchesRepoProjectDirectory(projectDirectory, currentPath) { + if (projectDirectory === ".") { + return true; + } + return currentPath === projectDirectory || currentPath.startsWith(`${projectDirectory}/`); +} +function resolveProjectJsonLink(dir) { + const raw = readJsonIfExists(join(dir, ".vercel", "project.json")); + if (!isRecord(raw)) return null; + const projectId = asNonEmptyString(raw.projectId); + const orgId = asNonEmptyString(raw.orgId); + if (!projectId || !orgId) return null; + return { + projectId, + orgId, + source: "project.json" + }; +} +function resolveRepoJsonLink(repoRoot, startPath) { + const raw = readJsonIfExists(join(repoRoot, ".vercel", "repo.json")); + if (!isRecord(raw) || !Array.isArray(raw.projects)) { + return null; + } + const repoOrgId = asNonEmptyString(raw.orgId); + const currentPath = normalizeRepoPath(relative(repoRoot, startPath)); + const candidates = raw.projects.filter(isRecord).map((project) => { + const projectId = asNonEmptyString(project.id); + const orgId = asNonEmptyString(project.orgId) ?? repoOrgId; + const directory = normalizeRepoPath(asNonEmptyString(project.directory) ?? "."); + if (!projectId || !orgId) { + return null; + } + return { + directory, + projectId, + orgId + }; + }).filter((candidate) => candidate !== null).filter((candidate) => matchesRepoProjectDirectory(candidate.directory, currentPath)).sort((left, right) => pathDepth(right.directory) - pathDepth(left.directory)); + if (candidates.length === 0) { + return null; + } + const deepestDepth = pathDepth(candidates[0].directory); + const deepestCandidates = candidates.filter((candidate) => pathDepth(candidate.directory) === deepestDepth); + if (deepestCandidates.length !== 1) { + return null; + } + return { + projectId: deepestCandidates[0].projectId, + orgId: deepestCandidates[0].orgId, + source: "repo.json" + }; +} +function resolveVercelProjectLink(startPath) { + let current = resolve(startPath); + while (true) { + const projectLink = resolveProjectJsonLink(current); + if (projectLink) { + return projectLink; + } + const repoJsonPath = join(current, ".vercel", "repo.json"); + if (existsSync(repoJsonPath)) { + return resolveRepoJsonLink(current, resolve(startPath)); + } + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} +function parseSessionVercelProjectLinkState(raw) { + if (raw.trim() === "") return null; + try { + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) return null; + const lastResolvedAt = parsed.lastResolvedAt; + if (typeof lastResolvedAt !== "number" || !Number.isFinite(lastResolvedAt)) { + return null; + } + const state = { lastResolvedAt }; + const projectId = asNonEmptyString(parsed.projectId); + const orgId = asNonEmptyString(parsed.orgId); + if (projectId) { + state.projectId = projectId; + } + if (orgId) { + state.orgId = orgId; + } + return state; + } catch { + return null; + } +} +function readSessionVercelProjectLinkState(sessionId) { + try { + const raw = readFileSync(dedupFilePath(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND), "utf-8"); + return parseSessionVercelProjectLinkState(raw); + } catch { + return null; + } +} +function writeSessionVercelProjectLinkState(sessionId, state) { + writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); +} +function shouldRefreshSessionVercelProjectLink(state, now, refreshMs) { + return !state || now - state.lastResolvedAt >= refreshMs; +} export { + SESSION_VERCEL_PROJECT_LINK_KIND, appendAuditLog, dedupClaimDirPath, dedupFilePath, generateVerificationId, getDedupScopeId, listSessionKeys, + parseSessionVercelProjectLinkState, pluginRoot, profileCachePath, readSessionFile, + readSessionVercelProjectLinkState, removeAllSessionDedupArtifacts, removeSessionClaimDir, + resolveVercelProjectLink, safeReadFile, safeReadJson, + shouldRefreshSessionVercelProjectLink, syncSessionFileFromClaims, tryClaimSessionKey, - writeSessionFile + writeSessionFile, + writeSessionVercelProjectLinkState }; diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index 92123db..0079ac8 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -15,7 +15,14 @@ import { normalizeInput, setSessionEnv } from "./compat.mjs"; -import { pluginRoot, profileCachePath, safeReadJson, writeSessionFile } from "./hook-env.mjs"; +import { + pluginRoot, + profileCachePath, + resolveVercelProjectLink, + safeReadJson, + writeSessionFile, + writeSessionVercelProjectLinkState +} from "./hook-env.mjs"; import { createLogger, logCaughtError } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; import { trackBaseEvents, getOrCreateDeviceId } from "./telemetry.mjs"; @@ -409,6 +416,7 @@ async function main() { const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveSessionStartProjectRoot(); + const vercelProjectLink = resolveVercelProjectLink(projectRoot); logBrokenSkillFrontmatterSummary(); const greenfield = checkGreenfield(projectRoot); const cliStatus = checkVercelCli(); @@ -426,6 +434,13 @@ async function main() { if (sessionId) { writeSessionFile(sessionId, SESSION_GREENFIELD_KIND, greenfieldValue); writeSessionFile(sessionId, SESSION_LIKELY_SKILLS_KIND, likelySkillsValue); + if (vercelProjectLink) { + writeSessionVercelProjectLinkState(sessionId, { + lastResolvedAt: Date.now(), + projectId: vercelProjectLink.projectId, + orgId: vercelProjectLink.orgId + }); + } } try { if (platform === "claude-code") { @@ -470,14 +485,21 @@ async function main() { } if (sessionId) { const deviceId = getOrCreateDeviceId(); - await trackBaseEvents(sessionId, [ + const telemetryEntries = [ { key: "session:device_id", value: deviceId }, { key: "session:platform", value: process.platform }, { key: "session:likely_skills", value: likelySkills.join(",") }, { key: "session:greenfield", value: String(greenfield !== null) }, { key: "session:vercel_cli_installed", value: String(cliStatus.installed) }, { key: "session:vercel_cli_version", value: cliStatus.currentVersion || "" } - ]).catch(() => { + ]; + if (vercelProjectLink) { + telemetryEntries.push( + { key: "session:vercel_project_id", value: vercelProjectLink.projectId }, + { key: "session:vercel_org_id", value: vercelProjectLink.orgId } + ); + } + await trackBaseEvents(sessionId, telemetryEntries).catch(() => { }); } if (cursorOutput) { diff --git a/hooks/src/hook-env.mts b/hooks/src/hook-env.mts index d0fe68f..e10427e 100644 --- a/hooks/src/hook-env.mts +++ b/hooks/src/hook-env.mts @@ -10,6 +10,7 @@ import { createHash, randomUUID } from "node:crypto"; import { appendFileSync, closeSync, + existsSync, mkdirSync, openSync, readFileSync, @@ -18,7 +19,7 @@ import { writeFileSync, } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { dirname, join, resolve, sep } from "node:path"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { createLogger, logCaughtError, type Logger } from "./logger.mjs"; @@ -320,3 +321,203 @@ export function safeReadJson(path: string): T | null { return null; } } + +// --------------------------------------------------------------------------- +// Vercel project linkage helpers +// --------------------------------------------------------------------------- + +export const SESSION_VERCEL_PROJECT_LINK_KIND = "vercel-project-link"; + +export interface VercelProjectLink { + projectId: string; + orgId: string; + source: "project.json" | "repo.json"; +} + +export interface SessionVercelProjectLinkState { + lastResolvedAt: number; + projectId?: string; + orgId?: string; +} + +interface RepoProjectCandidate { + directory: string; + projectId: string; + orgId: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readJsonIfExists(path: string): unknown | null { + if (!existsSync(path)) return null; + + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch { + return null; + } +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + +function normalizeRepoPath(pathValue: string): string { + const normalized = pathValue + .replaceAll("\\", "/") + .replace(/^\.\//, "") + .replace(/\/+$/, ""); + + return normalized === "" ? "." : normalized; +} + +function pathDepth(pathValue: string): number { + return pathValue === "." ? 0 : pathValue.split("/").length; +} + +function matchesRepoProjectDirectory(projectDirectory: string, currentPath: string): boolean { + if (projectDirectory === ".") { + return true; + } + + return currentPath === projectDirectory || currentPath.startsWith(`${projectDirectory}/`); +} + +function resolveProjectJsonLink(dir: string): VercelProjectLink | null { + const raw = readJsonIfExists(join(dir, ".vercel", "project.json")); + if (!isRecord(raw)) return null; + + const projectId = asNonEmptyString(raw.projectId); + const orgId = asNonEmptyString(raw.orgId); + if (!projectId || !orgId) return null; + + return { + projectId, + orgId, + source: "project.json", + }; +} + +function resolveRepoJsonLink(repoRoot: string, startPath: string): VercelProjectLink | null { + const raw = readJsonIfExists(join(repoRoot, ".vercel", "repo.json")); + if (!isRecord(raw) || !Array.isArray(raw.projects)) { + return null; + } + + const repoOrgId = asNonEmptyString(raw.orgId); + const currentPath = normalizeRepoPath(relative(repoRoot, startPath)); + const candidates: RepoProjectCandidate[] = raw.projects + .filter(isRecord) + .map((project) => { + const projectId = asNonEmptyString(project.id); + const orgId = asNonEmptyString(project.orgId) ?? repoOrgId; + const directory = normalizeRepoPath(asNonEmptyString(project.directory) ?? "."); + + if (!projectId || !orgId) { + return null; + } + + return { + directory, + projectId, + orgId, + }; + }) + .filter((candidate): candidate is RepoProjectCandidate => candidate !== null) + .filter((candidate) => matchesRepoProjectDirectory(candidate.directory, currentPath)) + .sort((left, right) => pathDepth(right.directory) - pathDepth(left.directory)); + + if (candidates.length === 0) { + return null; + } + + const deepestDepth = pathDepth(candidates[0].directory); + const deepestCandidates = candidates.filter((candidate) => pathDepth(candidate.directory) === deepestDepth); + if (deepestCandidates.length !== 1) { + return null; + } + + return { + projectId: deepestCandidates[0].projectId, + orgId: deepestCandidates[0].orgId, + source: "repo.json", + }; +} + +export function resolveVercelProjectLink(startPath: string): VercelProjectLink | null { + let current = resolve(startPath); + + while (true) { + const projectLink = resolveProjectJsonLink(current); + if (projectLink) { + return projectLink; + } + + const repoJsonPath = join(current, ".vercel", "repo.json"); + if (existsSync(repoJsonPath)) { + return resolveRepoJsonLink(current, resolve(startPath)); + } + + const parent = dirname(current); + if (parent === current) { + return null; + } + + current = parent; + } +} + +export function parseSessionVercelProjectLinkState(raw: string): SessionVercelProjectLinkState | null { + if (raw.trim() === "") return null; + + try { + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) return null; + + const lastResolvedAt = parsed.lastResolvedAt; + if (typeof lastResolvedAt !== "number" || !Number.isFinite(lastResolvedAt)) { + return null; + } + + const state: SessionVercelProjectLinkState = { lastResolvedAt }; + const projectId = asNonEmptyString(parsed.projectId); + const orgId = asNonEmptyString(parsed.orgId); + + if (projectId) { + state.projectId = projectId; + } + if (orgId) { + state.orgId = orgId; + } + + return state; + } catch { + return null; + } +} + +export function readSessionVercelProjectLinkState(sessionId: string): SessionVercelProjectLinkState | null { + try { + const raw = readFileSync(dedupFilePath(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND), "utf-8"); + return parseSessionVercelProjectLinkState(raw); + } catch { + return null; + } +} + +export function writeSessionVercelProjectLinkState( + sessionId: string, + state: SessionVercelProjectLinkState, +): void { + writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); +} + +export function shouldRefreshSessionVercelProjectLink( + state: SessionVercelProjectLinkState | null, + now: number, + refreshMs: number, +): boolean { + return !state || now - state.lastResolvedAt >= refreshMs; +} diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index 6c280c9..64f49cc 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -29,7 +29,14 @@ import { setSessionEnv, type HookPlatform, } from "./compat.mjs"; -import { pluginRoot, profileCachePath, safeReadJson, writeSessionFile } from "./hook-env.mjs"; +import { + pluginRoot, + profileCachePath, + resolveVercelProjectLink, + safeReadJson, + writeSessionFile, + writeSessionVercelProjectLinkState, +} from "./hook-env.mjs"; import { createLogger, logCaughtError, type Logger } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; import { trackBaseEvents, getOrCreateDeviceId } from "./telemetry.mjs"; @@ -619,6 +626,7 @@ async function main(): Promise { const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveSessionStartProjectRoot(); + const vercelProjectLink = resolveVercelProjectLink(projectRoot); logBrokenSkillFrontmatterSummary(); @@ -651,6 +659,13 @@ async function main(): Promise { if (sessionId) { writeSessionFile(sessionId, SESSION_GREENFIELD_KIND, greenfieldValue); writeSessionFile(sessionId, SESSION_LIKELY_SKILLS_KIND, likelySkillsValue); + if (vercelProjectLink) { + writeSessionVercelProjectLinkState(sessionId, { + lastResolvedAt: Date.now(), + projectId: vercelProjectLink.projectId, + orgId: vercelProjectLink.orgId, + }); + } } try { @@ -699,14 +714,23 @@ async function main(): Promise { // Base telemetry — enabled by default unless VERCEL_PLUGIN_TELEMETRY=off if (sessionId) { const deviceId = getOrCreateDeviceId(); - await trackBaseEvents(sessionId, [ + const telemetryEntries: Array<{ key: string; value: string }> = [ { key: "session:device_id", value: deviceId }, { key: "session:platform", value: process.platform }, { key: "session:likely_skills", value: likelySkills.join(",") }, { key: "session:greenfield", value: String(greenfield !== null) }, { key: "session:vercel_cli_installed", value: String(cliStatus.installed) }, { key: "session:vercel_cli_version", value: cliStatus.currentVersion || "" }, - ]).catch(() => {}); + ]; + + if (vercelProjectLink) { + telemetryEntries.push( + { key: "session:vercel_project_id", value: vercelProjectLink.projectId }, + { key: "session:vercel_org_id", value: vercelProjectLink.orgId }, + ); + } + + await trackBaseEvents(sessionId, telemetryEntries).catch(() => {}); } if (cursorOutput) { diff --git a/hooks/src/user-prompt-submit-telemetry.mts b/hooks/src/user-prompt-submit-telemetry.mts index 997b58a..c0f8b6d 100644 --- a/hooks/src/user-prompt-submit-telemetry.mts +++ b/hooks/src/user-prompt-submit-telemetry.mts @@ -8,7 +8,11 @@ * 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 + * 2. Refresh linked Vercel project metadata no more than once per hour. + * This is best-effort and only re-emits telemetry when the linked + * project/org IDs changed or were previously missing. + * + * 3. 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". @@ -25,10 +29,17 @@ 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, isPromptTelemetryEnabled, trackEvents } from "./telemetry.mjs"; +import { + readSessionVercelProjectLinkState, + resolveVercelProjectLink, + shouldRefreshSessionVercelProjectLink, + writeSessionVercelProjectLinkState, +} from "./hook-env.mjs"; +import { getTelemetryOverride, isPromptTelemetryEnabled, trackBaseEvents, trackEvents } from "./telemetry.mjs"; const PREF_PATH = join(homedir(), ".claude", "vercel-plugin-telemetry-preference"); const MIN_PROMPT_LENGTH = 10; +const VERCEL_PROJECT_LINK_REFRESH_MS = 60 * 60 * 1000; function parseStdin(): Record | null { try { @@ -48,12 +59,61 @@ function resolvePrompt(input: Record): string { return (input.prompt as string) || (input.message as string) || ""; } +function resolveProjectRoot(input: Record | null): string { + const cwd = input && typeof input.cwd === "string" && input.cwd.trim() !== "" + ? input.cwd + : null; + + return process.env.CLAUDE_PROJECT_ROOT + ?? process.env.CURSOR_PROJECT_DIR + ?? process.env.CLAUDE_PROJECT_DIR + ?? cwd + ?? process.cwd(); +} + +async function maybeTrackVercelProjectLink(sessionId: string, projectRoot: string): Promise { + const now = Date.now(); + const previousState = readSessionVercelProjectLinkState(sessionId); + if (!shouldRefreshSessionVercelProjectLink(previousState, now, VERCEL_PROJECT_LINK_REFRESH_MS)) { + return; + } + + const nextLink = resolveVercelProjectLink(projectRoot); + writeSessionVercelProjectLinkState( + sessionId, + nextLink + ? { + lastResolvedAt: now, + projectId: nextLink.projectId, + orgId: nextLink.orgId, + } + : { lastResolvedAt: now }, + ); + + if (!nextLink) { + return; + } + + if (previousState?.projectId === nextLink.projectId && previousState?.orgId === nextLink.orgId) { + return; + } + + await trackBaseEvents(sessionId, [ + { key: "session:vercel_project_id", value: nextLink.projectId }, + { key: "session:vercel_org_id", value: nextLink.orgId }, + ]).catch(() => {}); +} + async function main(): Promise { const input = parseStdin(); const sessionId = input ? resolveSessionId(input) : ""; const prompt = input ? resolvePrompt(input) : ""; const telemetryOverride = getTelemetryOverride(); + if (telemetryOverride !== "off" && sessionId) { + await maybeTrackVercelProjectLink(sessionId, resolveProjectRoot(input)); + } + // Prompt text tracking — opt-in only if (isPromptTelemetryEnabled() && sessionId && prompt.length >= MIN_PROMPT_LENGTH) { await trackEvents(sessionId, [ diff --git a/hooks/user-prompt-submit-telemetry.mjs b/hooks/user-prompt-submit-telemetry.mjs index 02c8e44..a7c1da5 100755 --- a/hooks/user-prompt-submit-telemetry.mjs +++ b/hooks/user-prompt-submit-telemetry.mjs @@ -4,9 +4,16 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; import { homedir, tmpdir } from "os"; import { join, dirname } from "path"; -import { getTelemetryOverride, isPromptTelemetryEnabled, trackEvents } from "./telemetry.mjs"; +import { + readSessionVercelProjectLinkState, + resolveVercelProjectLink, + shouldRefreshSessionVercelProjectLink, + writeSessionVercelProjectLinkState +} from "./hook-env.mjs"; +import { getTelemetryOverride, isPromptTelemetryEnabled, trackBaseEvents, trackEvents } from "./telemetry.mjs"; var PREF_PATH = join(homedir(), ".claude", "vercel-plugin-telemetry-preference"); var MIN_PROMPT_LENGTH = 10; +var VERCEL_PROJECT_LINK_REFRESH_MS = 60 * 60 * 1e3; function parseStdin() { try { const raw = readFileSync(0, "utf-8").trim(); @@ -22,11 +29,45 @@ function resolveSessionId(input) { function resolvePrompt(input) { return input.prompt || input.message || ""; } +function resolveProjectRoot(input) { + const cwd = input && typeof input.cwd === "string" && input.cwd.trim() !== "" ? input.cwd : null; + return process.env.CLAUDE_PROJECT_ROOT ?? process.env.CURSOR_PROJECT_DIR ?? process.env.CLAUDE_PROJECT_DIR ?? cwd ?? process.cwd(); +} +async function maybeTrackVercelProjectLink(sessionId, projectRoot) { + const now = Date.now(); + const previousState = readSessionVercelProjectLinkState(sessionId); + if (!shouldRefreshSessionVercelProjectLink(previousState, now, VERCEL_PROJECT_LINK_REFRESH_MS)) { + return; + } + const nextLink = resolveVercelProjectLink(projectRoot); + writeSessionVercelProjectLinkState( + sessionId, + nextLink ? { + lastResolvedAt: now, + projectId: nextLink.projectId, + orgId: nextLink.orgId + } : { lastResolvedAt: now } + ); + if (!nextLink) { + return; + } + if (previousState?.projectId === nextLink.projectId && previousState?.orgId === nextLink.orgId) { + return; + } + await trackBaseEvents(sessionId, [ + { key: "session:vercel_project_id", value: nextLink.projectId }, + { key: "session:vercel_org_id", value: nextLink.orgId } + ]).catch(() => { + }); +} async function main() { const input = parseStdin(); const sessionId = input ? resolveSessionId(input) : ""; const prompt = input ? resolvePrompt(input) : ""; const telemetryOverride = getTelemetryOverride(); + if (telemetryOverride !== "off" && sessionId) { + await maybeTrackVercelProjectLink(sessionId, resolveProjectRoot(input)); + } if (isPromptTelemetryEnabled() && sessionId && prompt.length >= MIN_PROMPT_LENGTH) { await trackEvents(sessionId, [ { key: "prompt:text", value: prompt } diff --git a/tests/session-start-profiler.test.ts b/tests/session-start-profiler.test.ts index 905b3aa..b61b1f6 100644 --- a/tests/session-start-profiler.test.ts +++ b/tests/session-start-profiler.test.ts @@ -10,7 +10,11 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { readSessionFile } from "../hooks/src/hook-env.mts"; +import { + readSessionFile, + readSessionVercelProjectLinkState, + resolveVercelProjectLink, +} from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); const PROFILER = join(ROOT, "hooks", "session-start-profiler.mjs"); @@ -69,6 +73,10 @@ function readGreenfieldState(): string { return readSessionFile(testSessionId, "greenfield"); } +function readVercelProjectLinkState() { + return readSessionVercelProjectLinkState(testSessionId); +} + function makeMockCommand(binDir: string, commandName: string, body: string): void { const commandPath = join(binDir, commandName); writeFileSync(commandPath, `#!/bin/sh\n${body}\n`, "utf-8"); @@ -570,6 +578,27 @@ describe("session-start-profiler", () => { expect(readGreenfieldState()).toBe("true"); }); + test("persists linked Vercel project IDs in session state when project.json is present", async () => { + const projectDir = join(tempDir, "linked-project"); + mkdirSync(join(projectDir, ".vercel"), { recursive: true }); + writeFileSync( + join(projectDir, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_linked", orgId: "team_linked" }), + ); + + const result = await runProfiler({ + CLAUDE_ENV_FILE: envFile, + CLAUDE_PROJECT_ROOT: projectDir, + }); + + expect(result.code).toBe(0); + expect(readVercelProjectLinkState()).toMatchObject({ + projectId: "prj_linked", + orgId: "team_linked", + }); + expect(readVercelProjectLinkState()?.lastResolvedAt).toEqual(expect.any(Number)); + }); + test("hooks.json registers profiler after seen-skills init", () => { const hooksJson = JSON.parse( readFileSync(join(ROOT, "hooks", "hooks.json"), "utf-8"), @@ -784,6 +813,86 @@ describe("profileProject (unit)", () => { }); }); +describe("resolveVercelProjectLink (unit)", () => { + test("reads .vercel/project.json from the nearest parent", () => { + const projectDir = join(tempDir, "unit-linked-project"); + const nestedDir = join(projectDir, "src", "app"); + mkdirSync(join(projectDir, ".vercel"), { recursive: true }); + mkdirSync(nestedDir, { recursive: true }); + writeFileSync( + join(projectDir, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_parent", orgId: "team_parent" }), + ); + + expect(resolveVercelProjectLink(nestedDir)).toEqual({ + projectId: "prj_parent", + orgId: "team_parent", + source: "project.json", + }); + }); + + test("reads matching subproject from repo.json", () => { + const repoRoot = join(tempDir, "unit-repo-linked"); + const webDir = join(repoRoot, "apps", "web"); + mkdirSync(join(repoRoot, ".vercel"), { recursive: true }); + mkdirSync(webDir, { recursive: true }); + writeFileSync( + join(repoRoot, ".vercel", "repo.json"), + JSON.stringify({ + orgId: "team_repo", + projects: [ + { id: "prj_root", directory: "." }, + { id: "prj_web", directory: "apps/web" }, + ], + }), + ); + + expect(resolveVercelProjectLink(webDir)).toEqual({ + projectId: "prj_web", + orgId: "team_repo", + source: "repo.json", + }); + }); + + test("falls back from settings-only project.json to repo.json", () => { + const repoRoot = join(tempDir, "unit-repo-settings-only"); + const webDir = join(repoRoot, "apps", "web"); + mkdirSync(join(repoRoot, ".vercel"), { recursive: true }); + mkdirSync(join(webDir, ".vercel"), { recursive: true }); + writeFileSync( + join(repoRoot, ".vercel", "repo.json"), + JSON.stringify({ + orgId: "team_repo", + projects: [{ id: "prj_web", directory: "apps/web" }], + }), + ); + writeFileSync(join(webDir, ".vercel", "project.json"), JSON.stringify({ settings: {} })); + + expect(resolveVercelProjectLink(webDir)).toEqual({ + projectId: "prj_web", + orgId: "team_repo", + source: "repo.json", + }); + }); + + test("returns null for an ambiguous repo root with multiple linked subprojects", () => { + const repoRoot = join(tempDir, "unit-repo-ambiguous"); + mkdirSync(join(repoRoot, ".vercel"), { recursive: true }); + writeFileSync( + join(repoRoot, ".vercel", "repo.json"), + JSON.stringify({ + orgId: "team_repo", + projects: [ + { id: "prj_web", directory: "apps/web" }, + { id: "prj_api", directory: "apps/api" }, + ], + }), + ); + + expect(resolveVercelProjectLink(repoRoot)).toBeNull(); + }); +}); + describe("logBrokenSkillFrontmatterSummary (unit)", () => { test("emits one summary warning when a skill has malformed frontmatter", async () => { const { logBrokenSkillFrontmatterSummary } = await import("../hooks/session-start-profiler.mjs"); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 6bed4ec..b79ebc1 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { shouldRefreshSessionVercelProjectLink } from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); const TELEMETRY_MODULE = join(ROOT, "hooks", "telemetry.mjs"); @@ -135,3 +136,17 @@ describe("telemetry controls", () => { expect(existsSync(prefPath)).toBe(false); }); }); + +describe("Vercel project link refresh", () => { + test("refreshes only when the cached link is missing or at least an hour old", () => { + const now = Date.now(); + + expect(shouldRefreshSessionVercelProjectLink(null, now, 3_600_000)).toBe(true); + expect( + shouldRefreshSessionVercelProjectLink({ lastResolvedAt: now - 3_599_999 }, now, 3_600_000), + ).toBe(false); + expect( + shouldRefreshSessionVercelProjectLink({ lastResolvedAt: now - 3_600_000 }, now, 3_600_000), + ).toBe(true); + }); +}); From 4797bdd046515708a2cfd17794c4a5e20a8f17f9 Mon Sep 17 00:00:00 2001 From: Grappeggia Date: Tue, 7 Apr 2026 15:57:20 -0700 Subject: [PATCH 2/9] fix vercel project link telemetry refreshes Prefer per-prompt roots over stale session env state and keep refresh retries alive until project IDs have actually been sent, so long-lived sessions do not misattribute or silently drop project telemetry after transient failures. --- hooks/hook-env.mjs | 22 ++++++- hooks/session-start-profiler.mjs | 20 +++--- hooks/src/hook-env.mts | 39 +++++++++++- hooks/src/session-start-profiler.mts | 20 +++--- hooks/src/telemetry.mts | 21 ++++--- hooks/src/user-prompt-submit-telemetry.mts | 55 ++++++++--------- hooks/telemetry.mjs | 14 +++-- hooks/user-prompt-submit-telemetry.mjs | 41 ++++++------- tests/telemetry.test.ts | 71 ++++++++++++++++++++-- 9 files changed, 212 insertions(+), 91 deletions(-) diff --git a/hooks/hook-env.mjs b/hooks/hook-env.mjs index 4af9bf7..6f4782d 100644 --- a/hooks/hook-env.mjs +++ b/hooks/hook-env.mjs @@ -213,6 +213,11 @@ function readJsonIfExists(path) { function asNonEmptyString(value) { return typeof value === "string" && value.trim() !== "" ? value : null; } +function resolveHookProjectRoot(input, env = process.env) { + const workspaceRoot = input && Array.isArray(input.workspace_roots) ? input.workspace_roots.find((entry) => typeof entry === "string" && entry.trim() !== "") : null; + const cwd = input && typeof input.cwd === "string" && input.cwd.trim() !== "" ? input.cwd : null; + return cwd ?? (typeof workspaceRoot === "string" ? workspaceRoot : null) ?? asNonEmptyString(env.CURSOR_PROJECT_DIR) ?? asNonEmptyString(env.CLAUDE_PROJECT_ROOT) ?? asNonEmptyString(env.CLAUDE_PROJECT_DIR) ?? process.cwd(); +} function normalizeRepoPath(pathValue) { const normalized = pathValue.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, ""); return normalized === "" ? "." : normalized; @@ -302,12 +307,20 @@ function parseSessionVercelProjectLinkState(raw) { const state = { lastResolvedAt }; const projectId = asNonEmptyString(parsed.projectId); const orgId = asNonEmptyString(parsed.orgId); + const lastSentProjectId = asNonEmptyString(parsed.lastSentProjectId); + const lastSentOrgId = asNonEmptyString(parsed.lastSentOrgId); if (projectId) { state.projectId = projectId; } if (orgId) { state.orgId = orgId; } + if (lastSentProjectId) { + state.lastSentProjectId = lastSentProjectId; + } + if (lastSentOrgId) { + state.lastSentOrgId = lastSentOrgId; + } return state; } catch { return null; @@ -324,8 +337,14 @@ function readSessionVercelProjectLinkState(sessionId) { function writeSessionVercelProjectLinkState(sessionId, state) { writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); } +function hasUnsentSessionVercelProjectLink(state) { + if (!state?.projectId || !state.orgId) { + return false; + } + return state.lastSentProjectId !== state.projectId || state.lastSentOrgId !== state.orgId; +} function shouldRefreshSessionVercelProjectLink(state, now, refreshMs) { - return !state || now - state.lastResolvedAt >= refreshMs; + return !state || hasUnsentSessionVercelProjectLink(state) || now - state.lastResolvedAt >= refreshMs; } export { SESSION_VERCEL_PROJECT_LINK_KIND, @@ -342,6 +361,7 @@ export { readSessionVercelProjectLinkState, removeAllSessionDedupArtifacts, removeSessionClaimDir, + resolveHookProjectRoot, resolveVercelProjectLink, safeReadFile, safeReadJson, diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index 0079ac8..7034843 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -417,6 +417,7 @@ async function main() { const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveSessionStartProjectRoot(); const vercelProjectLink = resolveVercelProjectLink(projectRoot); + const vercelProjectLinkResolvedAt = vercelProjectLink ? Date.now() : null; logBrokenSkillFrontmatterSummary(); const greenfield = checkGreenfield(projectRoot); const cliStatus = checkVercelCli(); @@ -434,13 +435,6 @@ async function main() { if (sessionId) { writeSessionFile(sessionId, SESSION_GREENFIELD_KIND, greenfieldValue); writeSessionFile(sessionId, SESSION_LIKELY_SKILLS_KIND, likelySkillsValue); - if (vercelProjectLink) { - writeSessionVercelProjectLinkState(sessionId, { - lastResolvedAt: Date.now(), - projectId: vercelProjectLink.projectId, - orgId: vercelProjectLink.orgId - }); - } } try { if (platform === "claude-code") { @@ -499,8 +493,16 @@ async function main() { { key: "session:vercel_org_id", value: vercelProjectLink.orgId } ); } - await trackBaseEvents(sessionId, telemetryEntries).catch(() => { - }); + const trackedBaseTelemetry = await trackBaseEvents(sessionId, telemetryEntries).catch(() => false); + if (vercelProjectLink && vercelProjectLinkResolvedAt !== null) { + writeSessionVercelProjectLinkState(sessionId, { + lastResolvedAt: vercelProjectLinkResolvedAt, + projectId: vercelProjectLink.projectId, + orgId: vercelProjectLink.orgId, + lastSentProjectId: trackedBaseTelemetry ? vercelProjectLink.projectId : void 0, + lastSentOrgId: trackedBaseTelemetry ? vercelProjectLink.orgId : void 0 + }); + } } if (cursorOutput) { process.stdout.write(cursorOutput); diff --git a/hooks/src/hook-env.mts b/hooks/src/hook-env.mts index e10427e..537226f 100644 --- a/hooks/src/hook-env.mts +++ b/hooks/src/hook-env.mts @@ -338,6 +338,8 @@ export interface SessionVercelProjectLinkState { lastResolvedAt: number; projectId?: string; orgId?: string; + lastSentProjectId?: string; + lastSentOrgId?: string; } interface RepoProjectCandidate { @@ -364,6 +366,25 @@ function asNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim() !== "" ? value : null; } +export function resolveHookProjectRoot( + input: Record | null, + env: NodeJS.ProcessEnv = process.env, +): string { + const workspaceRoot = input && Array.isArray(input.workspace_roots) + ? input.workspace_roots.find((entry) => typeof entry === "string" && entry.trim() !== "") + : null; + const cwd = input && typeof input.cwd === "string" && input.cwd.trim() !== "" + ? input.cwd + : null; + + return cwd + ?? (typeof workspaceRoot === "string" ? workspaceRoot : null) + ?? asNonEmptyString(env.CURSOR_PROJECT_DIR) + ?? asNonEmptyString(env.CLAUDE_PROJECT_ROOT) + ?? asNonEmptyString(env.CLAUDE_PROJECT_DIR) + ?? process.cwd(); +} + function normalizeRepoPath(pathValue: string): string { const normalized = pathValue .replaceAll("\\", "/") @@ -484,6 +505,8 @@ export function parseSessionVercelProjectLinkState(raw: string): SessionVercelPr const state: SessionVercelProjectLinkState = { lastResolvedAt }; const projectId = asNonEmptyString(parsed.projectId); const orgId = asNonEmptyString(parsed.orgId); + const lastSentProjectId = asNonEmptyString(parsed.lastSentProjectId); + const lastSentOrgId = asNonEmptyString(parsed.lastSentOrgId); if (projectId) { state.projectId = projectId; @@ -491,6 +514,12 @@ export function parseSessionVercelProjectLinkState(raw: string): SessionVercelPr if (orgId) { state.orgId = orgId; } + if (lastSentProjectId) { + state.lastSentProjectId = lastSentProjectId; + } + if (lastSentOrgId) { + state.lastSentOrgId = lastSentOrgId; + } return state; } catch { @@ -514,10 +543,18 @@ export function writeSessionVercelProjectLinkState( writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); } +function hasUnsentSessionVercelProjectLink(state: SessionVercelProjectLinkState | null): boolean { + if (!state?.projectId || !state.orgId) { + return false; + } + + return state.lastSentProjectId !== state.projectId || state.lastSentOrgId !== state.orgId; +} + export function shouldRefreshSessionVercelProjectLink( state: SessionVercelProjectLinkState | null, now: number, refreshMs: number, ): boolean { - return !state || now - state.lastResolvedAt >= refreshMs; + return !state || hasUnsentSessionVercelProjectLink(state) || now - state.lastResolvedAt >= refreshMs; } diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index 64f49cc..933ba74 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -627,6 +627,7 @@ async function main(): Promise { const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveSessionStartProjectRoot(); const vercelProjectLink = resolveVercelProjectLink(projectRoot); + const vercelProjectLinkResolvedAt = vercelProjectLink ? Date.now() : null; logBrokenSkillFrontmatterSummary(); @@ -659,13 +660,6 @@ async function main(): Promise { if (sessionId) { writeSessionFile(sessionId, SESSION_GREENFIELD_KIND, greenfieldValue); writeSessionFile(sessionId, SESSION_LIKELY_SKILLS_KIND, likelySkillsValue); - if (vercelProjectLink) { - writeSessionVercelProjectLinkState(sessionId, { - lastResolvedAt: Date.now(), - projectId: vercelProjectLink.projectId, - orgId: vercelProjectLink.orgId, - }); - } } try { @@ -730,7 +724,17 @@ async function main(): Promise { ); } - await trackBaseEvents(sessionId, telemetryEntries).catch(() => {}); + const trackedBaseTelemetry = await trackBaseEvents(sessionId, telemetryEntries).catch(() => false); + + if (vercelProjectLink && vercelProjectLinkResolvedAt !== null) { + writeSessionVercelProjectLinkState(sessionId, { + lastResolvedAt: vercelProjectLinkResolvedAt, + projectId: vercelProjectLink.projectId, + orgId: vercelProjectLink.orgId, + lastSentProjectId: trackedBaseTelemetry ? vercelProjectLink.projectId : undefined, + lastSentOrgId: trackedBaseTelemetry ? vercelProjectLink.orgId : undefined, + }); + } } if (cursorOutput) { diff --git a/hooks/src/telemetry.mts b/hooks/src/telemetry.mts index 9f4a839..d09e6e4 100644 --- a/hooks/src/telemetry.mts +++ b/hooks/src/telemetry.mts @@ -26,13 +26,13 @@ function truncateValue(value: string): string { return truncated + TRUNCATION_SUFFIX; } -async function send(sessionId: string, events: TelemetryEvent[]): Promise { - if (events.length === 0) return; +async function send(sessionId: string, events: TelemetryEvent[]): Promise { + if (events.length === 0) return false; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); try { - await fetch(BRIDGE_ENDPOINT, { + const response = await fetch(BRIDGE_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", @@ -42,8 +42,9 @@ async function send(sessionId: string, events: TelemetryEvent[]): Promise body: JSON.stringify(events), signal: controller.signal, }); + return response.ok; } catch { - // Best-effort + return false; } finally { clearTimeout(timeout); } @@ -119,8 +120,8 @@ export function isPromptTelemetryEnabled(env: NodeJS.ProcessEnv = process.env): // Always-on base telemetry (session, tool, skill injection events) // --------------------------------------------------------------------------- -export async function trackBaseEvent(sessionId: string, key: string, value: string): Promise { - if (!isBaseTelemetryEnabled()) return; +export async function trackBaseEvent(sessionId: string, key: string, value: string): Promise { + if (!isBaseTelemetryEnabled()) return false; const event: TelemetryEvent = { id: randomUUID(), @@ -129,14 +130,14 @@ export async function trackBaseEvent(sessionId: string, key: string, value: stri value: truncateValue(value), }; - await send(sessionId, [event]); + return send(sessionId, [event]); } export async function trackBaseEvents( sessionId: string, entries: Array<{ key: string; value: string }>, -): Promise { - if (!isBaseTelemetryEnabled() || entries.length === 0) return; +): Promise { + if (!isBaseTelemetryEnabled() || entries.length === 0) return false; const now = Date.now(); const events: TelemetryEvent[] = entries.map((entry) => ({ @@ -146,7 +147,7 @@ export async function trackBaseEvents( value: truncateValue(entry.value), })); - await send(sessionId, events); + return send(sessionId, events); } // --------------------------------------------------------------------------- diff --git a/hooks/src/user-prompt-submit-telemetry.mts b/hooks/src/user-prompt-submit-telemetry.mts index c0f8b6d..d6fe606 100644 --- a/hooks/src/user-prompt-submit-telemetry.mts +++ b/hooks/src/user-prompt-submit-telemetry.mts @@ -31,9 +31,11 @@ import { homedir, tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { readSessionVercelProjectLinkState, + resolveHookProjectRoot, resolveVercelProjectLink, shouldRefreshSessionVercelProjectLink, writeSessionVercelProjectLinkState, + type SessionVercelProjectLinkState, } from "./hook-env.mjs"; import { getTelemetryOverride, isPromptTelemetryEnabled, trackBaseEvents, trackEvents } from "./telemetry.mjs"; @@ -59,18 +61,6 @@ function resolvePrompt(input: Record): string { return (input.prompt as string) || (input.message as string) || ""; } -function resolveProjectRoot(input: Record | null): string { - const cwd = input && typeof input.cwd === "string" && input.cwd.trim() !== "" - ? input.cwd - : null; - - return process.env.CLAUDE_PROJECT_ROOT - ?? process.env.CURSOR_PROJECT_DIR - ?? process.env.CLAUDE_PROJECT_DIR - ?? cwd - ?? process.cwd(); -} - async function maybeTrackVercelProjectLink(sessionId: string, projectRoot: string): Promise { const now = Date.now(); const previousState = readSessionVercelProjectLinkState(sessionId); @@ -79,29 +69,34 @@ async function maybeTrackVercelProjectLink(sessionId: string, projectRoot: strin } const nextLink = resolveVercelProjectLink(projectRoot); - writeSessionVercelProjectLinkState( - sessionId, - nextLink - ? { - lastResolvedAt: now, - projectId: nextLink.projectId, - orgId: nextLink.orgId, - } - : { lastResolvedAt: now }, + const shouldTrackLink = !!nextLink && ( + previousState?.lastSentProjectId !== nextLink.projectId + || previousState?.lastSentOrgId !== nextLink.orgId ); + const trackedLink = shouldTrackLink + ? await trackBaseEvents(sessionId, [ + { key: "session:vercel_project_id", value: nextLink.projectId }, + { key: "session:vercel_org_id", value: nextLink.orgId }, + ]).catch(() => false) + : false; + + const nextState: SessionVercelProjectLinkState = { + lastResolvedAt: now, + lastSentProjectId: previousState?.lastSentProjectId, + lastSentOrgId: previousState?.lastSentOrgId, + }; - if (!nextLink) { - return; + if (nextLink) { + nextState.projectId = nextLink.projectId; + nextState.orgId = nextLink.orgId; } - if (previousState?.projectId === nextLink.projectId && previousState?.orgId === nextLink.orgId) { - return; + if (trackedLink && nextLink) { + nextState.lastSentProjectId = nextLink.projectId; + nextState.lastSentOrgId = nextLink.orgId; } - await trackBaseEvents(sessionId, [ - { key: "session:vercel_project_id", value: nextLink.projectId }, - { key: "session:vercel_org_id", value: nextLink.orgId }, - ]).catch(() => {}); + writeSessionVercelProjectLinkState(sessionId, nextState); } async function main(): Promise { @@ -111,7 +106,7 @@ async function main(): Promise { const telemetryOverride = getTelemetryOverride(); if (telemetryOverride !== "off" && sessionId) { - await maybeTrackVercelProjectLink(sessionId, resolveProjectRoot(input)); + await maybeTrackVercelProjectLink(sessionId, resolveHookProjectRoot(input)); } // Prompt text tracking — opt-in only diff --git a/hooks/telemetry.mjs b/hooks/telemetry.mjs index ecaf585..2d73b4e 100644 --- a/hooks/telemetry.mjs +++ b/hooks/telemetry.mjs @@ -16,11 +16,11 @@ function truncateValue(value) { return truncated + TRUNCATION_SUFFIX; } async function send(sessionId, events) { - if (events.length === 0) return; + if (events.length === 0) return false; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); try { - await fetch(BRIDGE_ENDPOINT, { + const response = await fetch(BRIDGE_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", @@ -30,7 +30,9 @@ async function send(sessionId, events) { body: JSON.stringify(events), signal: controller.signal }); + return response.ok; } catch { + return false; } finally { clearTimeout(timeout); } @@ -69,17 +71,17 @@ function isPromptTelemetryEnabled(env = process.env) { } } async function trackBaseEvent(sessionId, key, value) { - if (!isBaseTelemetryEnabled()) return; + if (!isBaseTelemetryEnabled()) return false; const event = { id: randomUUID(), event_time: Date.now(), key, value: truncateValue(value) }; - await send(sessionId, [event]); + return send(sessionId, [event]); } async function trackBaseEvents(sessionId, entries) { - if (!isBaseTelemetryEnabled() || entries.length === 0) return; + if (!isBaseTelemetryEnabled() || entries.length === 0) return false; const now = Date.now(); const events = entries.map((entry) => ({ id: randomUUID(), @@ -87,7 +89,7 @@ async function trackBaseEvents(sessionId, entries) { key: entry.key, value: truncateValue(entry.value) })); - await send(sessionId, events); + return send(sessionId, events); } async function trackEvent(sessionId, key, value) { if (!isPromptTelemetryEnabled()) return; diff --git a/hooks/user-prompt-submit-telemetry.mjs b/hooks/user-prompt-submit-telemetry.mjs index a7c1da5..81b22e2 100755 --- a/hooks/user-prompt-submit-telemetry.mjs +++ b/hooks/user-prompt-submit-telemetry.mjs @@ -6,6 +6,7 @@ import { homedir, tmpdir } from "os"; import { join, dirname } from "path"; import { readSessionVercelProjectLinkState, + resolveHookProjectRoot, resolveVercelProjectLink, shouldRefreshSessionVercelProjectLink, writeSessionVercelProjectLinkState @@ -29,10 +30,6 @@ function resolveSessionId(input) { function resolvePrompt(input) { return input.prompt || input.message || ""; } -function resolveProjectRoot(input) { - const cwd = input && typeof input.cwd === "string" && input.cwd.trim() !== "" ? input.cwd : null; - return process.env.CLAUDE_PROJECT_ROOT ?? process.env.CURSOR_PROJECT_DIR ?? process.env.CLAUDE_PROJECT_DIR ?? cwd ?? process.cwd(); -} async function maybeTrackVercelProjectLink(sessionId, projectRoot) { const now = Date.now(); const previousState = readSessionVercelProjectLinkState(sessionId); @@ -40,25 +37,25 @@ async function maybeTrackVercelProjectLink(sessionId, projectRoot) { return; } const nextLink = resolveVercelProjectLink(projectRoot); - writeSessionVercelProjectLinkState( - sessionId, - nextLink ? { - lastResolvedAt: now, - projectId: nextLink.projectId, - orgId: nextLink.orgId - } : { lastResolvedAt: now } - ); - if (!nextLink) { - return; - } - if (previousState?.projectId === nextLink.projectId && previousState?.orgId === nextLink.orgId) { - return; - } - await trackBaseEvents(sessionId, [ + const shouldTrackLink = !!nextLink && (previousState?.lastSentProjectId !== nextLink.projectId || previousState?.lastSentOrgId !== nextLink.orgId); + const trackedLink = shouldTrackLink ? await trackBaseEvents(sessionId, [ { key: "session:vercel_project_id", value: nextLink.projectId }, { key: "session:vercel_org_id", value: nextLink.orgId } - ]).catch(() => { - }); + ]).catch(() => false) : false; + const nextState = { + lastResolvedAt: now, + lastSentProjectId: previousState?.lastSentProjectId, + lastSentOrgId: previousState?.lastSentOrgId + }; + if (nextLink) { + nextState.projectId = nextLink.projectId; + nextState.orgId = nextLink.orgId; + } + if (trackedLink && nextLink) { + nextState.lastSentProjectId = nextLink.projectId; + nextState.lastSentOrgId = nextLink.orgId; + } + writeSessionVercelProjectLinkState(sessionId, nextState); } async function main() { const input = parseStdin(); @@ -66,7 +63,7 @@ async function main() { const prompt = input ? resolvePrompt(input) : ""; const telemetryOverride = getTelemetryOverride(); if (telemetryOverride !== "off" && sessionId) { - await maybeTrackVercelProjectLink(sessionId, resolveProjectRoot(input)); + await maybeTrackVercelProjectLink(sessionId, resolveHookProjectRoot(input)); } if (isPromptTelemetryEnabled() && sessionId && prompt.length >= MIN_PROMPT_LENGTH) { await trackEvents(sessionId, [ diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index b79ebc1..39b28f9 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { shouldRefreshSessionVercelProjectLink } from "../hooks/src/hook-env.mts"; +import { + parseSessionVercelProjectLinkState, + resolveHookProjectRoot, + shouldRefreshSessionVercelProjectLink, +} from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); const TELEMETRY_MODULE = join(ROOT, "hooks", "telemetry.mjs"); @@ -138,15 +142,74 @@ describe("telemetry controls", () => { }); describe("Vercel project link refresh", () => { - test("refreshes only when the cached link is missing or at least an hour old", () => { + test("prefers per-prompt roots over stale session env roots", () => { + const promptRoot = join(tempHome, "apps", "web"); + const workspaceRoot = join(tempHome, "apps", "api"); + const envRoot = join(tempHome, "stale-session-root"); + + expect(resolveHookProjectRoot({ cwd: promptRoot }, { CLAUDE_PROJECT_ROOT: envRoot })).toBe(promptRoot); + expect( + resolveHookProjectRoot( + { workspace_roots: [workspaceRoot] }, + { CLAUDE_PROJECT_ROOT: envRoot }, + ), + ).toBe(workspaceRoot); + }); + + test("parses last sent project metadata from session state", () => { + expect( + parseSessionVercelProjectLinkState(JSON.stringify({ + lastResolvedAt: 123, + projectId: "prj_current", + orgId: "team_current", + lastSentProjectId: "prj_sent", + lastSentOrgId: "team_sent", + })), + ).toEqual({ + lastResolvedAt: 123, + projectId: "prj_current", + orgId: "team_current", + lastSentProjectId: "prj_sent", + lastSentOrgId: "team_sent", + }); + }); + + test("refreshes when the cached link is missing, unsent, or at least an hour old", () => { const now = Date.now(); expect(shouldRefreshSessionVercelProjectLink(null, now, 3_600_000)).toBe(true); expect( - shouldRefreshSessionVercelProjectLink({ lastResolvedAt: now - 3_599_999 }, now, 3_600_000), + shouldRefreshSessionVercelProjectLink( + { lastResolvedAt: now - 1, projectId: "prj_unsent", orgId: "team_unsent" }, + now, + 3_600_000, + ), + ).toBe(true); + expect( + shouldRefreshSessionVercelProjectLink( + { + lastResolvedAt: now - 3_599_999, + projectId: "prj_sent", + orgId: "team_sent", + lastSentProjectId: "prj_sent", + lastSentOrgId: "team_sent", + }, + now, + 3_600_000, + ), ).toBe(false); expect( - shouldRefreshSessionVercelProjectLink({ lastResolvedAt: now - 3_600_000 }, now, 3_600_000), + shouldRefreshSessionVercelProjectLink( + { + lastResolvedAt: now - 3_600_000, + projectId: "prj_sent", + orgId: "team_sent", + lastSentProjectId: "prj_sent", + lastSentOrgId: "team_sent", + }, + now, + 3_600_000, + ), ).toBe(true); }); }); From 940a8cde173ac61b74f87a796775936399365acc Mon Sep 17 00:00:00 2001 From: Grappeggia Date: Tue, 7 Apr 2026 16:17:28 -0700 Subject: [PATCH 3/9] clear stale vercel project link session state Remove cached project link state when a session starts without a linked project, clear that state on clear/compact, and add regression coverage for prompt refreshes and cleanup paths. --- hooks/hook-env.mjs | 23 ++- hooks/session-start-profiler.mjs | 4 + hooks/src/hook-env.mts | 21 ++- hooks/src/session-start-profiler.mts | 4 + tests/session-end-cleanup.test.ts | 25 +++ tests/session-start-profiler.test.ts | 47 +++++ tests/session-start-seen-skills.test.ts | 15 ++ tests/telemetry.test.ts | 224 +++++++++++++++++++++++- 8 files changed, 357 insertions(+), 6 deletions(-) diff --git a/hooks/hook-env.mjs b/hooks/hook-env.mjs index 6f4782d..e5f6478 100644 --- a/hooks/hook-env.mjs +++ b/hooks/hook-env.mjs @@ -95,6 +95,13 @@ function writeSessionFile(sessionId, kind, value, scopeId) { logCaughtError(log, "hook-env:write-session-file-failed", error, { sessionId, kind, scopeId }); } } +function removeSessionFile(sessionId, kind, scopeId) { + try { + rmSync(dedupFilePath(sessionId, kind, scopeId), { force: true }); + } catch (error) { + logCaughtError(log, "hook-env:remove-session-file-failed", error, { sessionId, kind, scopeId }); + } +} function tryClaimSessionKey(sessionId, kind, key, scopeId) { try { const claimDir = dedupClaimDirPath(sessionId, kind, scopeId); @@ -132,7 +139,8 @@ function removeSessionClaimDir(sessionId, kind, scopeId) { } var CLEARABLE_SESSION_KINDS = /* @__PURE__ */ new Set([ "seen-skills", - "seen-context-chunks" + "seen-context-chunks", + "vercel-project-link" ]); function removeAllSessionDedupArtifacts(sessionId) { const result = { removedFiles: 0, removedDirs: 0 }; @@ -278,7 +286,8 @@ function resolveRepoJsonLink(repoRoot, startPath) { }; } function resolveVercelProjectLink(startPath) { - let current = resolve(startPath); + const resolvedStartPath = resolve(startPath); + let current = resolvedStartPath; while (true) { const projectLink = resolveProjectJsonLink(current); if (projectLink) { @@ -286,7 +295,10 @@ function resolveVercelProjectLink(startPath) { } const repoJsonPath = join(current, ".vercel", "repo.json"); if (existsSync(repoJsonPath)) { - return resolveRepoJsonLink(current, resolve(startPath)); + const repoLink = resolveRepoJsonLink(current, resolvedStartPath); + if (repoLink) { + return repoLink; + } } const parent = dirname(current); if (parent === current) { @@ -337,6 +349,9 @@ function readSessionVercelProjectLinkState(sessionId) { function writeSessionVercelProjectLinkState(sessionId, state) { writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); } +function removeSessionVercelProjectLinkState(sessionId) { + removeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND); +} function hasUnsentSessionVercelProjectLink(state) { if (!state?.projectId || !state.orgId) { return false; @@ -361,6 +376,8 @@ export { readSessionVercelProjectLinkState, removeAllSessionDedupArtifacts, removeSessionClaimDir, + removeSessionFile, + removeSessionVercelProjectLinkState, resolveHookProjectRoot, resolveVercelProjectLink, safeReadFile, diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index 7034843..d03197d 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -18,6 +18,7 @@ import { import { pluginRoot, profileCachePath, + removeSessionVercelProjectLinkState, resolveVercelProjectLink, safeReadJson, writeSessionFile, @@ -435,6 +436,9 @@ async function main() { if (sessionId) { writeSessionFile(sessionId, SESSION_GREENFIELD_KIND, greenfieldValue); writeSessionFile(sessionId, SESSION_LIKELY_SKILLS_KIND, likelySkillsValue); + if (!vercelProjectLink) { + removeSessionVercelProjectLinkState(sessionId); + } } try { if (platform === "claude-code") { diff --git a/hooks/src/hook-env.mts b/hooks/src/hook-env.mts index 537226f..f79e269 100644 --- a/hooks/src/hook-env.mts +++ b/hooks/src/hook-env.mts @@ -159,6 +159,14 @@ export function writeSessionFile(sessionId: string, kind: string, value: string, } } +export function removeSessionFile(sessionId: string, kind: string, scopeId?: string): void { + try { + rmSync(dedupFilePath(sessionId, kind, scopeId), { force: true }); + } catch (error) { + logCaughtError(log, "hook-env:remove-session-file-failed", error, { sessionId, kind, scopeId }); + } +} + export function tryClaimSessionKey(sessionId: string, kind: string, key: string, scopeId?: string): boolean { try { const claimDir = dedupClaimDirPath(sessionId, kind, scopeId); @@ -220,6 +228,7 @@ export interface RemoveArtifactsResult { const CLEARABLE_SESSION_KINDS = new Set([ "seen-skills", "seen-context-chunks", + "vercel-project-link", ]); export function removeAllSessionDedupArtifacts(sessionId: string): RemoveArtifactsResult { @@ -468,7 +477,8 @@ function resolveRepoJsonLink(repoRoot: string, startPath: string): VercelProject } export function resolveVercelProjectLink(startPath: string): VercelProjectLink | null { - let current = resolve(startPath); + const resolvedStartPath = resolve(startPath); + let current = resolvedStartPath; while (true) { const projectLink = resolveProjectJsonLink(current); @@ -478,7 +488,10 @@ export function resolveVercelProjectLink(startPath: string): VercelProjectLink | const repoJsonPath = join(current, ".vercel", "repo.json"); if (existsSync(repoJsonPath)) { - return resolveRepoJsonLink(current, resolve(startPath)); + const repoLink = resolveRepoJsonLink(current, resolvedStartPath); + if (repoLink) { + return repoLink; + } } const parent = dirname(current); @@ -543,6 +556,10 @@ export function writeSessionVercelProjectLinkState( writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); } +export function removeSessionVercelProjectLinkState(sessionId: string): void { + removeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND); +} + function hasUnsentSessionVercelProjectLink(state: SessionVercelProjectLinkState | null): boolean { if (!state?.projectId || !state.orgId) { return false; diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index 933ba74..cbb4e8d 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -32,6 +32,7 @@ import { import { pluginRoot, profileCachePath, + removeSessionVercelProjectLinkState, resolveVercelProjectLink, safeReadJson, writeSessionFile, @@ -660,6 +661,9 @@ async function main(): Promise { if (sessionId) { writeSessionFile(sessionId, SESSION_GREENFIELD_KIND, greenfieldValue); writeSessionFile(sessionId, SESSION_LIKELY_SKILLS_KIND, likelySkillsValue); + if (!vercelProjectLink) { + removeSessionVercelProjectLinkState(sessionId); + } } try { diff --git a/tests/session-end-cleanup.test.ts b/tests/session-end-cleanup.test.ts index e3e2c08..48ae82a 100644 --- a/tests/session-end-cleanup.test.ts +++ b/tests/session-end-cleanup.test.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { dedupFilePath } from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); const HOOK_SCRIPT = join(ROOT, "hooks", "session-end-cleanup.mjs"); @@ -74,6 +75,30 @@ describe("session-end-cleanup", () => { rmSync(pendingLaunchDir, { recursive: true, force: true }); } }); + + test("removes linked project telemetry state files for the session", async () => { + const sessionId = "cleanup-project-link-state"; + const projectLinkFile = dedupFilePath(sessionId, "vercel-project-link"); + + writeFileSync( + projectLinkFile, + JSON.stringify({ projectId: "prj_cleanup", orgId: "team_cleanup", lastResolvedAt: Date.now() }), + "utf-8", + ); + + try { + expect(existsSync(projectLinkFile)).toBe(true); + + const { code, stdout, stderr } = await runSessionEnd({ session_id: sessionId }); + + expect(code).toBe(0); + expect(stdout).toBe(""); + expect(stderr).toBe(""); + expect(existsSync(projectLinkFile)).toBe(false); + } finally { + rmSync(projectLinkFile, { force: true }); + } + }); }); describe("hooks.json wiring", () => { diff --git a/tests/session-start-profiler.test.ts b/tests/session-start-profiler.test.ts index b61b1f6..db1cdf9 100644 --- a/tests/session-start-profiler.test.ts +++ b/tests/session-start-profiler.test.ts @@ -14,6 +14,7 @@ import { readSessionFile, readSessionVercelProjectLinkState, resolveVercelProjectLink, + writeSessionVercelProjectLinkState, } from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); @@ -599,6 +600,26 @@ describe("session-start-profiler", () => { expect(readVercelProjectLinkState()?.lastResolvedAt).toEqual(expect.any(Number)); }); + test("clears stale linked Vercel project state when current project is unlinked", async () => { + const projectDir = join(tempDir, "unlinked-project"); + mkdirSync(projectDir); + writeSessionVercelProjectLinkState(testSessionId, { + lastResolvedAt: Date.now(), + projectId: "prj_stale", + orgId: "team_stale", + lastSentProjectId: "prj_stale", + lastSentOrgId: "team_stale", + }); + + const result = await runProfiler({ + CLAUDE_ENV_FILE: envFile, + CLAUDE_PROJECT_ROOT: projectDir, + }); + + expect(result.code).toBe(0); + expect(readVercelProjectLinkState()).toBeNull(); + }); + test("hooks.json registers profiler after seen-skills init", () => { const hooksJson = JSON.parse( readFileSync(join(ROOT, "hooks", "hooks.json"), "utf-8"), @@ -875,6 +896,32 @@ describe("resolveVercelProjectLink (unit)", () => { }); }); + test("falls back to an ancestor link when a nested repo.json has no matching project", () => { + const repoRoot = join(tempDir, "unit-repo-nested-fallback"); + const webDir = join(repoRoot, "apps", "web"); + const nestedDir = join(webDir, "src"); + mkdirSync(join(repoRoot, ".vercel"), { recursive: true }); + mkdirSync(join(webDir, ".vercel"), { recursive: true }); + mkdirSync(nestedDir, { recursive: true }); + writeFileSync( + join(repoRoot, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_ancestor", orgId: "team_ancestor" }), + ); + writeFileSync( + join(webDir, ".vercel", "repo.json"), + JSON.stringify({ + orgId: "team_nested", + projects: [{ id: "prj_other", directory: "apps/other" }], + }), + ); + + expect(resolveVercelProjectLink(nestedDir)).toEqual({ + projectId: "prj_ancestor", + orgId: "team_ancestor", + source: "project.json", + }); + }); + test("returns null for an ambiguous repo root with multiple linked subprojects", () => { const repoRoot = join(tempDir, "unit-repo-ambiguous"); mkdirSync(join(repoRoot, ".vercel"), { recursive: true }); diff --git a/tests/session-start-seen-skills.test.ts b/tests/session-start-seen-skills.test.ts index 7ede6ea..c0b9208 100644 --- a/tests/session-start-seen-skills.test.ts +++ b/tests/session-start-seen-skills.test.ts @@ -124,11 +124,17 @@ describe("session-start-seen-skills hook", () => { expect(tryClaimSessionKey(sessionId, "seen-context-chunks", "nextjs-platform")).toBe(true); writeFileSync(dedupFilePath(sessionId, "seen-skills"), "nextjs,ai-sdk", "utf-8"); writeFileSync(dedupFilePath(sessionId, "seen-context-chunks"), "nextjs-platform", "utf-8"); + writeFileSync( + dedupFilePath(sessionId, "vercel-project-link"), + JSON.stringify({ projectId: "prj_clear", orgId: "team_clear", lastResolvedAt: Date.now() }), + "utf-8", + ); expect(existsSync(dedupClaimDirPath(sessionId, "seen-skills"))).toBe(true); expect(existsSync(dedupFilePath(sessionId, "seen-skills"))).toBe(true); expect(existsSync(dedupClaimDirPath(sessionId, "seen-context-chunks"))).toBe(true); expect(existsSync(dedupFilePath(sessionId, "seen-context-chunks"))).toBe(true); + expect(existsSync(dedupFilePath(sessionId, "vercel-project-link"))).toBe(true); // Fire the hook with a "clear" event const result = await runSessionStart( @@ -144,11 +150,13 @@ describe("session-start-seen-skills hook", () => { expect(existsSync(dedupFilePath(sessionId, "seen-skills"))).toBe(false); expect(existsSync(dedupClaimDirPath(sessionId, "seen-context-chunks"))).toBe(false); expect(existsSync(dedupFilePath(sessionId, "seen-context-chunks"))).toBe(false); + expect(existsSync(dedupFilePath(sessionId, "vercel-project-link"))).toBe(false); } finally { rmSync(dedupClaimDirPath(sessionId, "seen-skills"), { recursive: true, force: true }); rmSync(dedupClaimDirPath(sessionId, "seen-context-chunks"), { recursive: true, force: true }); try { rmSync(dedupFilePath(sessionId, "seen-skills")); } catch {} try { rmSync(dedupFilePath(sessionId, "seen-context-chunks")); } catch {} + try { rmSync(dedupFilePath(sessionId, "vercel-project-link")); } catch {} } }); @@ -158,6 +166,11 @@ describe("session-start-seen-skills hook", () => { try { expect(tryClaimSessionKey(sessionId, "seen-skills", "swr")).toBe(true); writeFileSync(dedupFilePath(sessionId, "seen-skills"), "swr", "utf-8"); + writeFileSync( + dedupFilePath(sessionId, "vercel-project-link"), + JSON.stringify({ projectId: "prj_compact", orgId: "team_compact", lastResolvedAt: Date.now() }), + "utf-8", + ); const result = await runSessionStart( { CLAUDE_ENV_FILE: undefined }, @@ -167,9 +180,11 @@ describe("session-start-seen-skills hook", () => { expect(result.code).toBe(0); expect(existsSync(dedupClaimDirPath(sessionId, "seen-skills"))).toBe(false); expect(existsSync(dedupFilePath(sessionId, "seen-skills"))).toBe(false); + expect(existsSync(dedupFilePath(sessionId, "vercel-project-link"))).toBe(false); } finally { rmSync(dedupClaimDirPath(sessionId, "seen-skills"), { recursive: true, force: true }); try { rmSync(dedupFilePath(sessionId, "seen-skills")); } catch {} + try { rmSync(dedupFilePath(sessionId, "vercel-project-link")); } catch {} } }); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 39b28f9..b6123f8 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -1,11 +1,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, 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"; +import { pathToFileURL } from "node:url"; import { parseSessionVercelProjectLinkState, + readSessionVercelProjectLinkState, resolveHookProjectRoot, shouldRefreshSessionVercelProjectLink, + writeSessionVercelProjectLinkState, } from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); @@ -105,6 +108,94 @@ async function runPromptHook(env: Record): Promise<{ return { code, stdout, stderr }; } +async function runPromptHookWithCapture(args: { + env?: Record; + payload?: Record; +}): Promise<{ + code: number; + stdout: string; + stderr: string; + requests: Array<{ url: string; body: string | null; headers: Record | null }>; +}> { + const captureFile = join(tempHome, `prompt-hook-capture-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`); + const preloadFile = join(tempHome, `prompt-hook-preload-${Date.now()}-${Math.random().toString(36).slice(2)}.mjs`); + writeFileSync( + preloadFile, + [ + 'import { appendFileSync } from "node:fs";', + 'const captureFile = process.env.VERCEL_PLUGIN_CAPTURE_FILE;', + 'globalThis.fetch = async (url, options = {}) => {', + ' if (captureFile) {', + ' appendFileSync(captureFile, JSON.stringify({', + ' url: String(url),', + ' body: typeof options.body === "string" ? options.body : null,', + ' headers: options.headers && typeof options.headers === "object" ? options.headers : null,', + ' }) + "\\n", "utf-8");', + ' }', + ' return new Response(null, { status: 204 });', + '};', + ].join("\n"), + "utf-8", + ); + + const mergedEnv: Record = { + ...(process.env as Record), + VERCEL_PLUGIN_CAPTURE_FILE: captureFile, + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(preloadFile).href}`, + ].filter(Boolean).join(" "), + }; + + for (const [key, value] of Object.entries(args.env ?? {})) { + if (value === undefined) { + delete mergedEnv[key]; + continue; + } + mergedEnv[key] = value; + } + + const proc = Bun.spawn([NODE_BIN, USER_PROMPT_HOOK], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: mergedEnv, + }); + + proc.stdin.write(JSON.stringify(args.payload ?? { + 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(); + const requests = existsSync(captureFile) + ? readFileSync(captureFile, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as { url: string; body: string | null; headers: Record | null }) + : []; + + rmSync(captureFile, { force: true }); + rmSync(preloadFile, { force: true }); + + return { code, stdout, stderr, requests }; +} + +function writePromptTelemetryPreference(value: "enabled" | "disabled"): void { + const claudeDir = join(tempHome, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "vercel-plugin-telemetry-preference"), value, "utf-8"); +} + +function parseTrackedEntries(body: string | null): Array<{ key: string; value: string }> { + return (JSON.parse(body ?? "[]") as Array<{ key: string; value: string }>) + .map((entry) => ({ key: entry.key, value: entry.value })); +} + beforeEach(() => { tempHome = mkdtempSync(join(tmpdir(), "telemetry-home-")); }); @@ -212,4 +303,135 @@ describe("Vercel project link refresh", () => { ), ).toBe(true); }); + + test("prompt hook re-emits linked project ids when cwd resolves to a different linked project", async () => { + const sessionId = `telemetry-project-link-change-${Date.now()}`; + const staleRoot = join(tempHome, "stale-root"); + const currentRoot = join(tempHome, "apps", "web"); + mkdirSync(join(staleRoot, ".vercel"), { recursive: true }); + mkdirSync(join(currentRoot, ".vercel"), { recursive: true }); + writeFileSync( + join(staleRoot, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_stale", orgId: "team_stale" }), + "utf-8", + ); + writeFileSync( + join(currentRoot, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_current", orgId: "team_current" }), + "utf-8", + ); + writeSessionVercelProjectLinkState(sessionId, { + lastResolvedAt: Date.now() - 3_600_000, + projectId: "prj_stale", + orgId: "team_stale", + lastSentProjectId: "prj_stale", + lastSentOrgId: "team_stale", + }); + writePromptTelemetryPreference("disabled"); + + const result = await runPromptHookWithCapture({ + env: { + HOME: tempHome, + CLAUDE_PROJECT_ROOT: staleRoot, + }, + payload: { + session_id: sessionId, + prompt: "refresh project telemetry", + cwd: currentRoot, + }, + }); + + expect(result.code).toBe(0); + expect(result.stdout).toBe("{}"); + expect(result.requests).toHaveLength(1); + expect(parseTrackedEntries(result.requests[0].body)).toEqual([ + { key: "session:vercel_project_id", value: "prj_current" }, + { key: "session:vercel_org_id", value: "team_current" }, + ]); + expect(readSessionVercelProjectLinkState(sessionId)).toMatchObject({ + projectId: "prj_current", + orgId: "team_current", + lastSentProjectId: "prj_current", + lastSentOrgId: "team_current", + }); + }); + + test("prompt hook clears cached project ids when the current project is no longer linked", async () => { + const sessionId = `telemetry-project-link-removed-${Date.now()}`; + const staleRoot = join(tempHome, "old-linked-root"); + const unlinkedRoot = join(tempHome, "plain-project"); + mkdirSync(join(staleRoot, ".vercel"), { recursive: true }); + mkdirSync(unlinkedRoot, { recursive: true }); + writeFileSync( + join(staleRoot, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_old", orgId: "team_old" }), + "utf-8", + ); + writeSessionVercelProjectLinkState(sessionId, { + lastResolvedAt: Date.now() - 3_600_000, + projectId: "prj_old", + orgId: "team_old", + lastSentProjectId: "prj_old", + lastSentOrgId: "team_old", + }); + writePromptTelemetryPreference("disabled"); + + const result = await runPromptHookWithCapture({ + env: { + HOME: tempHome, + CLAUDE_PROJECT_ROOT: staleRoot, + }, + payload: { + session_id: sessionId, + prompt: "refresh project telemetry", + cwd: unlinkedRoot, + }, + }); + + expect(result.code).toBe(0); + expect(result.stdout).toBe("{}"); + expect(result.requests).toHaveLength(0); + const state = readSessionVercelProjectLinkState(sessionId); + expect(state?.projectId).toBeUndefined(); + expect(state?.orgId).toBeUndefined(); + expect(state?.lastSentProjectId).toBe("prj_old"); + expect(state?.lastSentOrgId).toBe("team_old"); + expect(state?.lastResolvedAt).toEqual(expect.any(Number)); + }); + + test("prompt hook does not re-emit linked project ids within the refresh window", async () => { + const sessionId = `telemetry-project-link-unchanged-${Date.now()}`; + const linkedRoot = join(tempHome, "steady-linked-root"); + mkdirSync(join(linkedRoot, ".vercel"), { recursive: true }); + writeFileSync( + join(linkedRoot, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_same", orgId: "team_same" }), + "utf-8", + ); + const initialState = { + lastResolvedAt: Date.now(), + projectId: "prj_same", + orgId: "team_same", + lastSentProjectId: "prj_same", + lastSentOrgId: "team_same", + }; + writeSessionVercelProjectLinkState(sessionId, initialState); + writePromptTelemetryPreference("disabled"); + + const result = await runPromptHookWithCapture({ + env: { + HOME: tempHome, + }, + payload: { + session_id: sessionId, + prompt: "refresh project telemetry", + cwd: linkedRoot, + }, + }); + + expect(result.code).toBe(0); + expect(result.stdout).toBe("{}"); + expect(result.requests).toHaveLength(0); + expect(readSessionVercelProjectLinkState(sessionId)).toEqual(initialState); + }); }); From d8fcb87aed66f0ab72fd4f8399c7f922a04e2fe7 Mon Sep 17 00:00:00 2001 From: Grappeggia Date: Tue, 7 Apr 2026 16:46:32 -0700 Subject: [PATCH 4/9] refresh project link telemetry on root changes Re-resolve linked Vercel project metadata when the working root changes and emit tombstones when sessions become unlinked so dashboard attribution does not stay stale. --- hooks/hook-env.mjs | 24 ++-- hooks/session-start-profiler.mjs | 35 +++--- hooks/src/hook-env.mts | 28 +++-- hooks/src/session-start-profiler.mts | 42 ++++--- hooks/src/user-prompt-submit-telemetry.mts | 45 +++++--- hooks/user-prompt-submit-telemetry.mjs | 35 ++++-- tests/session-start-profiler.test.ts | 125 ++++++++++++++++++++- tests/telemetry.test.ts | 50 +++++++-- 8 files changed, 289 insertions(+), 95 deletions(-) diff --git a/hooks/hook-env.mjs b/hooks/hook-env.mjs index e5f6478..0320a80 100644 --- a/hooks/hook-env.mjs +++ b/hooks/hook-env.mjs @@ -95,13 +95,6 @@ function writeSessionFile(sessionId, kind, value, scopeId) { logCaughtError(log, "hook-env:write-session-file-failed", error, { sessionId, kind, scopeId }); } } -function removeSessionFile(sessionId, kind, scopeId) { - try { - rmSync(dedupFilePath(sessionId, kind, scopeId), { force: true }); - } catch (error) { - logCaughtError(log, "hook-env:remove-session-file-failed", error, { sessionId, kind, scopeId }); - } -} function tryClaimSessionKey(sessionId, kind, key, scopeId) { try { const claimDir = dedupClaimDirPath(sessionId, kind, scopeId); @@ -317,10 +310,14 @@ function parseSessionVercelProjectLinkState(raw) { return null; } const state = { lastResolvedAt }; + const lastResolvedRoot = asNonEmptyString(parsed.lastResolvedRoot); const projectId = asNonEmptyString(parsed.projectId); const orgId = asNonEmptyString(parsed.orgId); const lastSentProjectId = asNonEmptyString(parsed.lastSentProjectId); const lastSentOrgId = asNonEmptyString(parsed.lastSentOrgId); + if (lastResolvedRoot) { + state.lastResolvedRoot = lastResolvedRoot; + } if (projectId) { state.projectId = projectId; } @@ -349,17 +346,14 @@ function readSessionVercelProjectLinkState(sessionId) { function writeSessionVercelProjectLinkState(sessionId, state) { writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); } -function removeSessionVercelProjectLinkState(sessionId) { - removeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND); -} function hasUnsentSessionVercelProjectLink(state) { - if (!state?.projectId || !state.orgId) { + if (!state) { return false; } - return state.lastSentProjectId !== state.projectId || state.lastSentOrgId !== state.orgId; + return (state.projectId ?? null) !== (state.lastSentProjectId ?? null) || (state.orgId ?? null) !== (state.lastSentOrgId ?? null); } -function shouldRefreshSessionVercelProjectLink(state, now, refreshMs) { - return !state || hasUnsentSessionVercelProjectLink(state) || now - state.lastResolvedAt >= refreshMs; +function shouldRefreshSessionVercelProjectLink(state, currentProjectRoot, now, refreshMs) { + return !state || state.lastResolvedRoot !== currentProjectRoot || hasUnsentSessionVercelProjectLink(state) || now - state.lastResolvedAt >= refreshMs; } export { SESSION_VERCEL_PROJECT_LINK_KIND, @@ -376,8 +370,6 @@ export { readSessionVercelProjectLinkState, removeAllSessionDedupArtifacts, removeSessionClaimDir, - removeSessionFile, - removeSessionVercelProjectLinkState, resolveHookProjectRoot, resolveVercelProjectLink, safeReadFile, diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index d03197d..c0c8a27 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -18,7 +18,7 @@ import { import { pluginRoot, profileCachePath, - removeSessionVercelProjectLinkState, + readSessionVercelProjectLinkState, resolveVercelProjectLink, safeReadJson, writeSessionFile, @@ -417,8 +417,9 @@ async function main() { const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveSessionStartProjectRoot(); + const previousVercelProjectLinkState = sessionId ? readSessionVercelProjectLinkState(sessionId) : null; const vercelProjectLink = resolveVercelProjectLink(projectRoot); - const vercelProjectLinkResolvedAt = vercelProjectLink ? Date.now() : null; + const vercelProjectLinkResolvedAt = Date.now(); logBrokenSkillFrontmatterSummary(); const greenfield = checkGreenfield(projectRoot); const cliStatus = checkVercelCli(); @@ -436,9 +437,6 @@ async function main() { if (sessionId) { writeSessionFile(sessionId, SESSION_GREENFIELD_KIND, greenfieldValue); writeSessionFile(sessionId, SESSION_LIKELY_SKILLS_KIND, likelySkillsValue); - if (!vercelProjectLink) { - removeSessionVercelProjectLinkState(sessionId); - } } try { if (platform === "claude-code") { @@ -496,17 +494,28 @@ async function main() { { key: "session:vercel_project_id", value: vercelProjectLink.projectId }, { key: "session:vercel_org_id", value: vercelProjectLink.orgId } ); + } else if (previousVercelProjectLinkState?.lastSentProjectId !== void 0 || previousVercelProjectLinkState?.lastSentOrgId !== void 0) { + telemetryEntries.push( + { key: "session:vercel_project_id", value: "" }, + { key: "session:vercel_org_id", value: "" } + ); } const trackedBaseTelemetry = await trackBaseEvents(sessionId, telemetryEntries).catch(() => false); - if (vercelProjectLink && vercelProjectLinkResolvedAt !== null) { - writeSessionVercelProjectLinkState(sessionId, { - lastResolvedAt: vercelProjectLinkResolvedAt, - projectId: vercelProjectLink.projectId, - orgId: vercelProjectLink.orgId, - lastSentProjectId: trackedBaseTelemetry ? vercelProjectLink.projectId : void 0, - lastSentOrgId: trackedBaseTelemetry ? vercelProjectLink.orgId : void 0 - }); + const nextVercelProjectLinkState = { + lastResolvedAt: vercelProjectLinkResolvedAt, + lastResolvedRoot: projectRoot, + lastSentProjectId: trackedBaseTelemetry ? void 0 : previousVercelProjectLinkState?.lastSentProjectId, + lastSentOrgId: trackedBaseTelemetry ? void 0 : previousVercelProjectLinkState?.lastSentOrgId + }; + if (vercelProjectLink) { + nextVercelProjectLinkState.projectId = vercelProjectLink.projectId; + nextVercelProjectLinkState.orgId = vercelProjectLink.orgId; + if (trackedBaseTelemetry) { + nextVercelProjectLinkState.lastSentProjectId = vercelProjectLink.projectId; + nextVercelProjectLinkState.lastSentOrgId = vercelProjectLink.orgId; + } } + writeSessionVercelProjectLinkState(sessionId, nextVercelProjectLinkState); } if (cursorOutput) { process.stdout.write(cursorOutput); diff --git a/hooks/src/hook-env.mts b/hooks/src/hook-env.mts index f79e269..5cc7f51 100644 --- a/hooks/src/hook-env.mts +++ b/hooks/src/hook-env.mts @@ -159,14 +159,6 @@ export function writeSessionFile(sessionId: string, kind: string, value: string, } } -export function removeSessionFile(sessionId: string, kind: string, scopeId?: string): void { - try { - rmSync(dedupFilePath(sessionId, kind, scopeId), { force: true }); - } catch (error) { - logCaughtError(log, "hook-env:remove-session-file-failed", error, { sessionId, kind, scopeId }); - } -} - export function tryClaimSessionKey(sessionId: string, kind: string, key: string, scopeId?: string): boolean { try { const claimDir = dedupClaimDirPath(sessionId, kind, scopeId); @@ -345,6 +337,7 @@ export interface VercelProjectLink { export interface SessionVercelProjectLinkState { lastResolvedAt: number; + lastResolvedRoot?: string; projectId?: string; orgId?: string; lastSentProjectId?: string; @@ -516,11 +509,15 @@ export function parseSessionVercelProjectLinkState(raw: string): SessionVercelPr } const state: SessionVercelProjectLinkState = { lastResolvedAt }; + const lastResolvedRoot = asNonEmptyString(parsed.lastResolvedRoot); const projectId = asNonEmptyString(parsed.projectId); const orgId = asNonEmptyString(parsed.orgId); const lastSentProjectId = asNonEmptyString(parsed.lastSentProjectId); const lastSentOrgId = asNonEmptyString(parsed.lastSentOrgId); + if (lastResolvedRoot) { + state.lastResolvedRoot = lastResolvedRoot; + } if (projectId) { state.projectId = projectId; } @@ -556,22 +553,23 @@ export function writeSessionVercelProjectLinkState( writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); } -export function removeSessionVercelProjectLinkState(sessionId: string): void { - removeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND); -} - function hasUnsentSessionVercelProjectLink(state: SessionVercelProjectLinkState | null): boolean { - if (!state?.projectId || !state.orgId) { + if (!state) { return false; } - return state.lastSentProjectId !== state.projectId || state.lastSentOrgId !== state.orgId; + return (state.projectId ?? null) !== (state.lastSentProjectId ?? null) + || (state.orgId ?? null) !== (state.lastSentOrgId ?? null); } export function shouldRefreshSessionVercelProjectLink( state: SessionVercelProjectLinkState | null, + currentProjectRoot: string, now: number, refreshMs: number, ): boolean { - return !state || hasUnsentSessionVercelProjectLink(state) || now - state.lastResolvedAt >= refreshMs; + return !state + || state.lastResolvedRoot !== currentProjectRoot + || hasUnsentSessionVercelProjectLink(state) + || now - state.lastResolvedAt >= refreshMs; } diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index cbb4e8d..cac24a7 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -32,11 +32,12 @@ import { import { pluginRoot, profileCachePath, - removeSessionVercelProjectLinkState, + readSessionVercelProjectLinkState, resolveVercelProjectLink, safeReadJson, writeSessionFile, writeSessionVercelProjectLinkState, + type SessionVercelProjectLinkState, } from "./hook-env.mjs"; import { createLogger, logCaughtError, type Logger } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; @@ -627,8 +628,11 @@ async function main(): Promise { const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveSessionStartProjectRoot(); + const previousVercelProjectLinkState = sessionId + ? readSessionVercelProjectLinkState(sessionId) + : null; const vercelProjectLink = resolveVercelProjectLink(projectRoot); - const vercelProjectLinkResolvedAt = vercelProjectLink ? Date.now() : null; + const vercelProjectLinkResolvedAt = Date.now(); logBrokenSkillFrontmatterSummary(); @@ -661,9 +665,6 @@ async function main(): Promise { if (sessionId) { writeSessionFile(sessionId, SESSION_GREENFIELD_KIND, greenfieldValue); writeSessionFile(sessionId, SESSION_LIKELY_SKILLS_KIND, likelySkillsValue); - if (!vercelProjectLink) { - removeSessionVercelProjectLinkState(sessionId); - } } try { @@ -726,19 +727,34 @@ async function main(): Promise { { key: "session:vercel_project_id", value: vercelProjectLink.projectId }, { key: "session:vercel_org_id", value: vercelProjectLink.orgId }, ); + } else if ( + previousVercelProjectLinkState?.lastSentProjectId !== undefined + || previousVercelProjectLinkState?.lastSentOrgId !== undefined + ) { + telemetryEntries.push( + { key: "session:vercel_project_id", value: "" }, + { key: "session:vercel_org_id", value: "" }, + ); } const trackedBaseTelemetry = await trackBaseEvents(sessionId, telemetryEntries).catch(() => false); + const nextVercelProjectLinkState: SessionVercelProjectLinkState = { + lastResolvedAt: vercelProjectLinkResolvedAt, + lastResolvedRoot: projectRoot, + lastSentProjectId: trackedBaseTelemetry ? undefined : previousVercelProjectLinkState?.lastSentProjectId, + lastSentOrgId: trackedBaseTelemetry ? undefined : previousVercelProjectLinkState?.lastSentOrgId, + }; - if (vercelProjectLink && vercelProjectLinkResolvedAt !== null) { - writeSessionVercelProjectLinkState(sessionId, { - lastResolvedAt: vercelProjectLinkResolvedAt, - projectId: vercelProjectLink.projectId, - orgId: vercelProjectLink.orgId, - lastSentProjectId: trackedBaseTelemetry ? vercelProjectLink.projectId : undefined, - lastSentOrgId: trackedBaseTelemetry ? vercelProjectLink.orgId : undefined, - }); + if (vercelProjectLink) { + nextVercelProjectLinkState.projectId = vercelProjectLink.projectId; + nextVercelProjectLinkState.orgId = vercelProjectLink.orgId; + if (trackedBaseTelemetry) { + nextVercelProjectLinkState.lastSentProjectId = vercelProjectLink.projectId; + nextVercelProjectLinkState.lastSentOrgId = vercelProjectLink.orgId; + } } + + writeSessionVercelProjectLinkState(sessionId, nextVercelProjectLinkState); } if (cursorOutput) { diff --git a/hooks/src/user-prompt-submit-telemetry.mts b/hooks/src/user-prompt-submit-telemetry.mts index d6fe606..b40b1bc 100644 --- a/hooks/src/user-prompt-submit-telemetry.mts +++ b/hooks/src/user-prompt-submit-telemetry.mts @@ -64,36 +64,51 @@ function resolvePrompt(input: Record): string { async function maybeTrackVercelProjectLink(sessionId: string, projectRoot: string): Promise { const now = Date.now(); const previousState = readSessionVercelProjectLinkState(sessionId); - if (!shouldRefreshSessionVercelProjectLink(previousState, now, VERCEL_PROJECT_LINK_REFRESH_MS)) { + if (!shouldRefreshSessionVercelProjectLink(previousState, projectRoot, now, VERCEL_PROJECT_LINK_REFRESH_MS)) { return; } const nextLink = resolveVercelProjectLink(projectRoot); - const shouldTrackLink = !!nextLink && ( - previousState?.lastSentProjectId !== nextLink.projectId - || previousState?.lastSentOrgId !== nextLink.orgId - ); - const trackedLink = shouldTrackLink - ? await trackBaseEvents(sessionId, [ + const telemetryEntries: Array<{ key: string; value: string }> = []; + + if (nextLink) { + if ( + previousState?.lastSentProjectId !== nextLink.projectId + || previousState?.lastSentOrgId !== nextLink.orgId + ) { + telemetryEntries.push( { key: "session:vercel_project_id", value: nextLink.projectId }, { key: "session:vercel_org_id", value: nextLink.orgId }, - ]).catch(() => false) + ); + } + } else if ( + previousState?.lastSentProjectId !== undefined + || previousState?.lastSentOrgId !== undefined + ) { + telemetryEntries.push( + { key: "session:vercel_project_id", value: "" }, + { key: "session:vercel_org_id", value: "" }, + ); + } + + const trackedLink = telemetryEntries.length > 0 + ? await trackBaseEvents(sessionId, telemetryEntries).catch(() => false) : false; const nextState: SessionVercelProjectLinkState = { lastResolvedAt: now, - lastSentProjectId: previousState?.lastSentProjectId, - lastSentOrgId: previousState?.lastSentOrgId, + lastResolvedRoot: projectRoot, + lastSentProjectId: trackedLink ? undefined : previousState?.lastSentProjectId, + lastSentOrgId: trackedLink ? undefined : previousState?.lastSentOrgId, }; if (nextLink) { nextState.projectId = nextLink.projectId; nextState.orgId = nextLink.orgId; - } - - if (trackedLink && nextLink) { - nextState.lastSentProjectId = nextLink.projectId; - nextState.lastSentOrgId = nextLink.orgId; + if (trackedLink) { + nextState.lastSentProjectId = nextLink.projectId; + nextState.lastSentOrgId = nextLink.orgId; + } } writeSessionVercelProjectLinkState(sessionId, nextState); diff --git a/hooks/user-prompt-submit-telemetry.mjs b/hooks/user-prompt-submit-telemetry.mjs index 81b22e2..90623c9 100755 --- a/hooks/user-prompt-submit-telemetry.mjs +++ b/hooks/user-prompt-submit-telemetry.mjs @@ -33,27 +33,38 @@ function resolvePrompt(input) { async function maybeTrackVercelProjectLink(sessionId, projectRoot) { const now = Date.now(); const previousState = readSessionVercelProjectLinkState(sessionId); - if (!shouldRefreshSessionVercelProjectLink(previousState, now, VERCEL_PROJECT_LINK_REFRESH_MS)) { + if (!shouldRefreshSessionVercelProjectLink(previousState, projectRoot, now, VERCEL_PROJECT_LINK_REFRESH_MS)) { return; } const nextLink = resolveVercelProjectLink(projectRoot); - const shouldTrackLink = !!nextLink && (previousState?.lastSentProjectId !== nextLink.projectId || previousState?.lastSentOrgId !== nextLink.orgId); - const trackedLink = shouldTrackLink ? await trackBaseEvents(sessionId, [ - { key: "session:vercel_project_id", value: nextLink.projectId }, - { key: "session:vercel_org_id", value: nextLink.orgId } - ]).catch(() => false) : false; + const telemetryEntries = []; + if (nextLink) { + if (previousState?.lastSentProjectId !== nextLink.projectId || previousState?.lastSentOrgId !== nextLink.orgId) { + telemetryEntries.push( + { key: "session:vercel_project_id", value: nextLink.projectId }, + { key: "session:vercel_org_id", value: nextLink.orgId } + ); + } + } else if (previousState?.lastSentProjectId !== void 0 || previousState?.lastSentOrgId !== void 0) { + telemetryEntries.push( + { key: "session:vercel_project_id", value: "" }, + { key: "session:vercel_org_id", value: "" } + ); + } + const trackedLink = telemetryEntries.length > 0 ? await trackBaseEvents(sessionId, telemetryEntries).catch(() => false) : false; const nextState = { lastResolvedAt: now, - lastSentProjectId: previousState?.lastSentProjectId, - lastSentOrgId: previousState?.lastSentOrgId + lastResolvedRoot: projectRoot, + lastSentProjectId: trackedLink ? void 0 : previousState?.lastSentProjectId, + lastSentOrgId: trackedLink ? void 0 : previousState?.lastSentOrgId }; if (nextLink) { nextState.projectId = nextLink.projectId; nextState.orgId = nextLink.orgId; - } - if (trackedLink && nextLink) { - nextState.lastSentProjectId = nextLink.projectId; - nextState.lastSentOrgId = nextLink.orgId; + if (trackedLink) { + nextState.lastSentProjectId = nextLink.projectId; + nextState.lastSentOrgId = nextLink.orgId; + } } writeSessionVercelProjectLinkState(sessionId, nextState); } diff --git a/tests/session-start-profiler.test.ts b/tests/session-start-profiler.test.ts index db1cdf9..e6f679f 100644 --- a/tests/session-start-profiler.test.ts +++ b/tests/session-start-profiler.test.ts @@ -10,6 +10,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { readSessionFile, readSessionVercelProjectLinkState, @@ -59,6 +60,84 @@ async function runProfiler(env: Record): Promise<{ return { code, stdout, stderr }; } +async function runProfilerWithCapture(args: { + env: Record; + payload?: Record; +}): Promise<{ + code: number; + stdout: string; + stderr: string; + requests: Array<{ url: string; body: string | null }>; +}> { + const captureFile = join(tempDir, `profiler-capture-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`); + const preloadFile = join(tempDir, `profiler-preload-${Date.now()}-${Math.random().toString(36).slice(2)}.mjs`); + writeFileSync( + preloadFile, + [ + 'import { appendFileSync } from "node:fs";', + 'const captureFile = process.env.VERCEL_PLUGIN_CAPTURE_FILE;', + 'globalThis.fetch = async (url, options = {}) => {', + ' if (captureFile) {', + ' appendFileSync(captureFile, JSON.stringify({', + ' url: String(url),', + ' body: typeof options.body === "string" ? options.body : null,', + ' }) + "\\n", "utf-8");', + ' }', + ' return new Response(null, { status: 204 });', + '};', + ].join("\n"), + "utf-8", + ); + + const mergedEnv: Record = { + ...(process.env as Record), + VERCEL_PLUGIN_CAPTURE_FILE: captureFile, + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(preloadFile).href}`, + ].filter(Boolean).join(" "), + }; + + for (const [key, value] of Object.entries(args.env)) { + if (value === undefined) { + delete mergedEnv[key]; + continue; + } + mergedEnv[key] = value; + } + + const proc = Bun.spawn([NODE_BIN, PROFILER], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: mergedEnv, + }); + + proc.stdin.write(JSON.stringify(args.payload ?? { session_id: testSessionId })); + proc.stdin.end(); + + const code = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const requests = existsSync(captureFile) + ? readFileSync(captureFile, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as { url: string; body: string | null }) + : []; + + rmSync(captureFile, { force: true }); + rmSync(preloadFile, { force: true }); + + return { code, stdout, stderr, requests }; +} + +function parseTrackedEntries(body: string | null): Array<{ key: string; value: string }> { + return (JSON.parse(body ?? "[]") as Array<{ key: string; value: string }>) + .map((entry) => ({ key: entry.key, value: entry.value })); +} + function parseLikelySkills(_envFileContent?: string): string[] { return readSessionFile(testSessionId, "likely-skills").split(",").filter(Boolean); } @@ -594,17 +673,19 @@ describe("session-start-profiler", () => { expect(result.code).toBe(0); expect(readVercelProjectLinkState()).toMatchObject({ + lastResolvedRoot: projectDir, projectId: "prj_linked", orgId: "team_linked", }); expect(readVercelProjectLinkState()?.lastResolvedAt).toEqual(expect.any(Number)); }); - test("clears stale linked Vercel project state when current project is unlinked", async () => { + test("replaces stale linked Vercel project state when current project is unlinked", async () => { const projectDir = join(tempDir, "unlinked-project"); mkdirSync(projectDir); writeSessionVercelProjectLinkState(testSessionId, { lastResolvedAt: Date.now(), + lastResolvedRoot: join(tempDir, "old-linked-root"), projectId: "prj_stale", orgId: "team_stale", lastSentProjectId: "prj_stale", @@ -617,7 +698,47 @@ describe("session-start-profiler", () => { }); expect(result.code).toBe(0); - expect(readVercelProjectLinkState()).toBeNull(); + expect(readVercelProjectLinkState()).toMatchObject({ + lastResolvedRoot: projectDir, + }); + expect(readVercelProjectLinkState()?.projectId).toBeUndefined(); + expect(readVercelProjectLinkState()?.orgId).toBeUndefined(); + expect(readVercelProjectLinkState()?.lastSentProjectId).toBeUndefined(); + expect(readVercelProjectLinkState()?.lastSentOrgId).toBeUndefined(); + }); + + test("emits tombstone telemetry when a previously linked session starts unlinked", async () => { + const projectDir = join(tempDir, "session-start-unlinked"); + mkdirSync(projectDir); + writeSessionVercelProjectLinkState(testSessionId, { + lastResolvedAt: Date.now(), + lastResolvedRoot: join(tempDir, "old-linked-root"), + projectId: "prj_old", + orgId: "team_old", + lastSentProjectId: "prj_old", + lastSentOrgId: "team_old", + }); + + const result = await runProfilerWithCapture({ + env: { + CLAUDE_ENV_FILE: envFile, + CLAUDE_PROJECT_ROOT: projectDir, + }, + }); + + expect(result.code).toBe(0); + expect(result.requests).toHaveLength(1); + expect(parseTrackedEntries(result.requests[0].body)).toContainEqual({ + key: "session:vercel_project_id", + value: "", + }); + expect(parseTrackedEntries(result.requests[0].body)).toContainEqual({ + key: "session:vercel_org_id", + value: "", + }); + expect(readVercelProjectLinkState()?.lastResolvedRoot).toBe(projectDir); + expect(readVercelProjectLinkState()?.lastSentProjectId).toBeUndefined(); + expect(readVercelProjectLinkState()?.lastSentOrgId).toBeUndefined(); }); test("hooks.json registers profiler after seen-skills init", () => { diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index b6123f8..e0b8f82 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -251,6 +251,7 @@ describe("Vercel project link refresh", () => { expect( parseSessionVercelProjectLinkState(JSON.stringify({ lastResolvedAt: 123, + lastResolvedRoot: "/repo/apps/web", projectId: "prj_current", orgId: "team_current", lastSentProjectId: "prj_sent", @@ -258,6 +259,7 @@ describe("Vercel project link refresh", () => { })), ).toEqual({ lastResolvedAt: 123, + lastResolvedRoot: "/repo/apps/web", projectId: "prj_current", orgId: "team_current", lastSentProjectId: "prj_sent", @@ -265,13 +267,30 @@ describe("Vercel project link refresh", () => { }); }); - test("refreshes when the cached link is missing, unsent, or at least an hour old", () => { + test("refreshes when the cached link is missing, root changed, unsent, or at least an hour old", () => { const now = Date.now(); + const currentRoot = "/repo/apps/web"; - expect(shouldRefreshSessionVercelProjectLink(null, now, 3_600_000)).toBe(true); + expect(shouldRefreshSessionVercelProjectLink(null, currentRoot, now, 3_600_000)).toBe(true); expect( shouldRefreshSessionVercelProjectLink( - { lastResolvedAt: now - 1, projectId: "prj_unsent", orgId: "team_unsent" }, + { lastResolvedAt: now - 1, projectId: "prj_unsent", orgId: "team_unsent", lastResolvedRoot: currentRoot }, + currentRoot, + now, + 3_600_000, + ), + ).toBe(true); + expect( + shouldRefreshSessionVercelProjectLink( + { + lastResolvedAt: now - 1, + lastResolvedRoot: "/repo/apps/api", + projectId: "prj_sent", + orgId: "team_sent", + lastSentProjectId: "prj_sent", + lastSentOrgId: "team_sent", + }, + currentRoot, now, 3_600_000, ), @@ -280,11 +299,13 @@ describe("Vercel project link refresh", () => { shouldRefreshSessionVercelProjectLink( { lastResolvedAt: now - 3_599_999, + lastResolvedRoot: currentRoot, projectId: "prj_sent", orgId: "team_sent", lastSentProjectId: "prj_sent", lastSentOrgId: "team_sent", }, + currentRoot, now, 3_600_000, ), @@ -293,18 +314,20 @@ describe("Vercel project link refresh", () => { shouldRefreshSessionVercelProjectLink( { lastResolvedAt: now - 3_600_000, + lastResolvedRoot: currentRoot, projectId: "prj_sent", orgId: "team_sent", lastSentProjectId: "prj_sent", lastSentOrgId: "team_sent", }, + currentRoot, now, 3_600_000, ), ).toBe(true); }); - test("prompt hook re-emits linked project ids when cwd resolves to a different linked project", async () => { + test("prompt hook re-resolves immediately when cwd changes to a different linked project", async () => { const sessionId = `telemetry-project-link-change-${Date.now()}`; const staleRoot = join(tempHome, "stale-root"); const currentRoot = join(tempHome, "apps", "web"); @@ -321,7 +344,8 @@ describe("Vercel project link refresh", () => { "utf-8", ); writeSessionVercelProjectLinkState(sessionId, { - lastResolvedAt: Date.now() - 3_600_000, + lastResolvedAt: Date.now(), + lastResolvedRoot: staleRoot, projectId: "prj_stale", orgId: "team_stale", lastSentProjectId: "prj_stale", @@ -349,6 +373,7 @@ describe("Vercel project link refresh", () => { { key: "session:vercel_org_id", value: "team_current" }, ]); expect(readSessionVercelProjectLinkState(sessionId)).toMatchObject({ + lastResolvedRoot: currentRoot, projectId: "prj_current", orgId: "team_current", lastSentProjectId: "prj_current", @@ -368,7 +393,8 @@ describe("Vercel project link refresh", () => { "utf-8", ); writeSessionVercelProjectLinkState(sessionId, { - lastResolvedAt: Date.now() - 3_600_000, + lastResolvedAt: Date.now(), + lastResolvedRoot: staleRoot, projectId: "prj_old", orgId: "team_old", lastSentProjectId: "prj_old", @@ -390,12 +416,17 @@ describe("Vercel project link refresh", () => { expect(result.code).toBe(0); expect(result.stdout).toBe("{}"); - expect(result.requests).toHaveLength(0); + expect(result.requests).toHaveLength(1); + expect(parseTrackedEntries(result.requests[0].body)).toEqual([ + { key: "session:vercel_project_id", value: "" }, + { key: "session:vercel_org_id", value: "" }, + ]); const state = readSessionVercelProjectLinkState(sessionId); + expect(state?.lastResolvedRoot).toBe(unlinkedRoot); expect(state?.projectId).toBeUndefined(); expect(state?.orgId).toBeUndefined(); - expect(state?.lastSentProjectId).toBe("prj_old"); - expect(state?.lastSentOrgId).toBe("team_old"); + expect(state?.lastSentProjectId).toBeUndefined(); + expect(state?.lastSentOrgId).toBeUndefined(); expect(state?.lastResolvedAt).toEqual(expect.any(Number)); }); @@ -410,6 +441,7 @@ describe("Vercel project link refresh", () => { ); const initialState = { lastResolvedAt: Date.now(), + lastResolvedRoot: linkedRoot, projectId: "prj_same", orgId: "team_same", lastSentProjectId: "prj_same", From 23ce9466a9d230713f52436ed83b54a2eb3f99ca Mon Sep 17 00:00:00 2001 From: Grappeggia Date: Tue, 7 Apr 2026 17:22:23 -0700 Subject: [PATCH 5/9] fix session-start project link telemetry regressions Use the hook payload root during session start and preserve linked-project session state across clear and compact events so attribution and unlink tombstones stay accurate. --- hooks/hook-env.mjs | 3 +- hooks/session-hooks-platform-compat.test.ts | 2 - hooks/session-start-profiler.mjs | 5 ++- hooks/src/hook-env.mts | 1 - hooks/src/session-start-profiler.mts | 5 ++- tests/session-start-profiler.test.ts | 49 +++++++++++++++++++++ tests/session-start-seen-skills.test.ts | 11 +++-- 7 files changed, 61 insertions(+), 15 deletions(-) diff --git a/hooks/hook-env.mjs b/hooks/hook-env.mjs index 0320a80..1a0fa89 100644 --- a/hooks/hook-env.mjs +++ b/hooks/hook-env.mjs @@ -132,8 +132,7 @@ function removeSessionClaimDir(sessionId, kind, scopeId) { } var CLEARABLE_SESSION_KINDS = /* @__PURE__ */ new Set([ "seen-skills", - "seen-context-chunks", - "vercel-project-link" + "seen-context-chunks" ]); function removeAllSessionDedupArtifacts(sessionId) { const result = { removedFiles: 0, removedDirs: 0 }; diff --git a/hooks/session-hooks-platform-compat.test.ts b/hooks/session-hooks-platform-compat.test.ts index 318ea94..e8e292b 100644 --- a/hooks/session-hooks-platform-compat.test.ts +++ b/hooks/session-hooks-platform-compat.test.ts @@ -52,7 +52,6 @@ describe("session hook platform compatibility", () => { {}, ); const envVars = buildSessionStartProfilerEnvVars({ - agentBrowserAvailable: true, greenfield: true, likelySkills: ["ai-sdk", "nextjs"], setupSignals: { @@ -68,7 +67,6 @@ describe("session hook platform compatibility", () => { ); expect(JSON.parse(formatSessionStartProfilerCursorOutput(envVars, ["profile ready"]))).toEqual({ env: { - VERCEL_PLUGIN_AGENT_BROWSER_AVAILABLE: "1", VERCEL_PLUGIN_GREENFIELD: "true", VERCEL_PLUGIN_LIKELY_SKILLS: "ai-sdk,nextjs", VERCEL_PLUGIN_BOOTSTRAP_HINTS: "greenfield", diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index c0c8a27..9feab1c 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -19,6 +19,7 @@ import { pluginRoot, profileCachePath, readSessionVercelProjectLinkState, + resolveHookProjectRoot, resolveVercelProjectLink, safeReadJson, writeSessionFile, @@ -328,7 +329,7 @@ function normalizeSessionStartSessionId(input) { return sessionId || null; } function resolveSessionStartProjectRoot(env = process.env) { - return env.CLAUDE_PROJECT_ROOT ?? env.CURSOR_PROJECT_DIR ?? process.cwd(); + return resolveHookProjectRoot(null, env); } function collectBrokenSkillFrontmatterNames(files) { return [...new Set( @@ -416,7 +417,7 @@ async function main() { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); - const projectRoot = resolveSessionStartProjectRoot(); + const projectRoot = resolveHookProjectRoot(hookInput); const previousVercelProjectLinkState = sessionId ? readSessionVercelProjectLinkState(sessionId) : null; const vercelProjectLink = resolveVercelProjectLink(projectRoot); const vercelProjectLinkResolvedAt = Date.now(); diff --git a/hooks/src/hook-env.mts b/hooks/src/hook-env.mts index 5cc7f51..048b8c1 100644 --- a/hooks/src/hook-env.mts +++ b/hooks/src/hook-env.mts @@ -220,7 +220,6 @@ export interface RemoveArtifactsResult { const CLEARABLE_SESSION_KINDS = new Set([ "seen-skills", "seen-context-chunks", - "vercel-project-link", ]); export function removeAllSessionDedupArtifacts(sessionId: string): RemoveArtifactsResult { diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index cac24a7..42b9834 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -33,6 +33,7 @@ import { pluginRoot, profileCachePath, readSessionVercelProjectLinkState, + resolveHookProjectRoot, resolveVercelProjectLink, safeReadJson, writeSessionFile, @@ -510,7 +511,7 @@ export function normalizeSessionStartSessionId(input: SessionStartInput | null): } export function resolveSessionStartProjectRoot(env: NodeJS.ProcessEnv = process.env): string { - return env.CLAUDE_PROJECT_ROOT ?? env.CURSOR_PROJECT_DIR ?? process.cwd(); + return resolveHookProjectRoot(null, env); } function collectBrokenSkillFrontmatterNames(files: string[]): string[] { @@ -627,7 +628,7 @@ async function main(): Promise { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); - const projectRoot = resolveSessionStartProjectRoot(); + const projectRoot = resolveHookProjectRoot(hookInput as Record | null); const previousVercelProjectLinkState = sessionId ? readSessionVercelProjectLinkState(sessionId) : null; diff --git a/tests/session-start-profiler.test.ts b/tests/session-start-profiler.test.ts index e6f679f..5314992 100644 --- a/tests/session-start-profiler.test.ts +++ b/tests/session-start-profiler.test.ts @@ -680,6 +680,55 @@ describe("session-start-profiler", () => { expect(readVercelProjectLinkState()?.lastResolvedAt).toEqual(expect.any(Number)); }); + test("prefers payload cwd over stale env roots when resolving linked Vercel project telemetry", async () => { + const staleRoot = join(tempDir, "stale-linked-root"); + const currentRoot = join(tempDir, "current-linked-root"); + mkdirSync(join(staleRoot, ".vercel"), { recursive: true }); + mkdirSync(join(currentRoot, ".vercel"), { recursive: true }); + writeFileSync( + join(staleRoot, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_stale", orgId: "team_stale" }), + ); + writeFileSync( + join(currentRoot, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_current", orgId: "team_current" }), + ); + + const result = await runProfilerWithCapture({ + env: { + CLAUDE_ENV_FILE: envFile, + CLAUDE_PROJECT_ROOT: staleRoot, + }, + payload: { + session_id: testSessionId, + cwd: currentRoot, + }, + }); + + expect(result.code).toBe(0); + expect(result.requests).toHaveLength(1); + const trackedEntries = parseTrackedEntries(result.requests[0].body); + expect(trackedEntries).toContainEqual({ + key: "session:vercel_project_id", + value: "prj_current", + }); + expect(trackedEntries).toContainEqual({ + key: "session:vercel_org_id", + value: "team_current", + }); + expect(trackedEntries).not.toContainEqual({ + key: "session:vercel_project_id", + value: "prj_stale", + }); + expect(readVercelProjectLinkState()).toMatchObject({ + lastResolvedRoot: currentRoot, + projectId: "prj_current", + orgId: "team_current", + lastSentProjectId: "prj_current", + lastSentOrgId: "team_current", + }); + }); + test("replaces stale linked Vercel project state when current project is unlinked", async () => { const projectDir = join(tempDir, "unlinked-project"); mkdirSync(projectDir); diff --git a/tests/session-start-seen-skills.test.ts b/tests/session-start-seen-skills.test.ts index c0b9208..4961e2a 100644 --- a/tests/session-start-seen-skills.test.ts +++ b/tests/session-start-seen-skills.test.ts @@ -6,7 +6,6 @@ import { join, resolve, sep } from "node:path"; import { dedupClaimDirPath, dedupFilePath, - removeAllSessionDedupArtifacts, removeSessionClaimDir, tryClaimSessionKey, } from "../hooks/src/hook-env.mts"; @@ -114,7 +113,7 @@ describe("session-start-seen-skills hook", () => { }); }); - test("test_clear_event_wipes_claim_dir_and_session_file", async () => { + test("test_clear_event_wipes_dedup_state_but_preserves_project_link_state", async () => { const sessionId = `test-clear-${Date.now()}`; try { @@ -145,12 +144,12 @@ describe("session-start-seen-skills hook", () => { expect(result.code).toBe(0); expect(result.stdout).toBe(""); - // Both claim dir and session file should be gone + // Dedup claim dirs/files should be gone, but project link state should remain expect(existsSync(dedupClaimDirPath(sessionId, "seen-skills"))).toBe(false); expect(existsSync(dedupFilePath(sessionId, "seen-skills"))).toBe(false); expect(existsSync(dedupClaimDirPath(sessionId, "seen-context-chunks"))).toBe(false); expect(existsSync(dedupFilePath(sessionId, "seen-context-chunks"))).toBe(false); - expect(existsSync(dedupFilePath(sessionId, "vercel-project-link"))).toBe(false); + expect(existsSync(dedupFilePath(sessionId, "vercel-project-link"))).toBe(true); } finally { rmSync(dedupClaimDirPath(sessionId, "seen-skills"), { recursive: true, force: true }); rmSync(dedupClaimDirPath(sessionId, "seen-context-chunks"), { recursive: true, force: true }); @@ -160,7 +159,7 @@ describe("session-start-seen-skills hook", () => { } }); - test("test_compact_event_wipes_claim_dir_and_session_file", async () => { + test("test_compact_event_wipes_dedup_state_but_preserves_project_link_state", async () => { const sessionId = `test-compact-${Date.now()}`; try { @@ -180,7 +179,7 @@ describe("session-start-seen-skills hook", () => { expect(result.code).toBe(0); expect(existsSync(dedupClaimDirPath(sessionId, "seen-skills"))).toBe(false); expect(existsSync(dedupFilePath(sessionId, "seen-skills"))).toBe(false); - expect(existsSync(dedupFilePath(sessionId, "vercel-project-link"))).toBe(false); + expect(existsSync(dedupFilePath(sessionId, "vercel-project-link"))).toBe(true); } finally { rmSync(dedupClaimDirPath(sessionId, "seen-skills"), { recursive: true, force: true }); try { rmSync(dedupFilePath(sessionId, "seen-skills")); } catch {} From bb6200a480e719a15c00f0246b5599e687c9fa04 Mon Sep 17 00:00:00 2001 From: Grappeggia Date: Tue, 7 Apr 2026 17:29:27 -0700 Subject: [PATCH 6/9] skip unnecessary project link telemetry work Avoid session-start link resolution and state writes when base telemetry is disabled, and centralize Vercel project link state construction so session-start and prompt refreshes share one clearer code path. --- hooks/hook-env.mjs | 20 ++++++++ hooks/session-start-profiler.mjs | 36 ++++++--------- hooks/src/hook-env.mts | 34 ++++++++++++++ hooks/src/session-start-profiler.mts | 41 ++++++----------- hooks/src/user-prompt-submit-telemetry.mts | 26 ++++------- hooks/user-prompt-submit-telemetry.mjs | 23 ++++------ tests/session-start-profiler.test.ts | 21 +++++++++ tests/telemetry.test.ts | 53 ++++++++++++++++++++++ 8 files changed, 172 insertions(+), 82 deletions(-) diff --git a/hooks/hook-env.mjs b/hooks/hook-env.mjs index 1a0fa89..d629d74 100644 --- a/hooks/hook-env.mjs +++ b/hooks/hook-env.mjs @@ -345,6 +345,25 @@ function readSessionVercelProjectLinkState(sessionId) { function writeSessionVercelProjectLinkState(sessionId, state) { writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); } +function buildSessionVercelProjectLinkState(args) { + const nextState = { + lastResolvedAt: args.resolvedAt, + lastResolvedRoot: args.projectRoot + }; + if (args.nextLink) { + nextState.projectId = args.nextLink.projectId; + nextState.orgId = args.nextLink.orgId; + } + const lastSentProjectId = args.trackedTelemetry ? args.nextLink?.projectId : args.previousState?.lastSentProjectId; + const lastSentOrgId = args.trackedTelemetry ? args.nextLink?.orgId : args.previousState?.lastSentOrgId; + if (lastSentProjectId !== void 0) { + nextState.lastSentProjectId = lastSentProjectId; + } + if (lastSentOrgId !== void 0) { + nextState.lastSentOrgId = lastSentOrgId; + } + return nextState; +} function hasUnsentSessionVercelProjectLink(state) { if (!state) { return false; @@ -357,6 +376,7 @@ function shouldRefreshSessionVercelProjectLink(state, currentProjectRoot, now, r export { SESSION_VERCEL_PROJECT_LINK_KIND, appendAuditLog, + buildSessionVercelProjectLinkState, dedupClaimDirPath, dedupFilePath, generateVerificationId, diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index 9feab1c..6647481 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -16,6 +16,7 @@ import { setSessionEnv } from "./compat.mjs"; import { + buildSessionVercelProjectLinkState, pluginRoot, profileCachePath, readSessionVercelProjectLinkState, @@ -27,7 +28,7 @@ import { } from "./hook-env.mjs"; import { createLogger, logCaughtError } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; -import { trackBaseEvents, getOrCreateDeviceId } from "./telemetry.mjs"; +import { getOrCreateDeviceId, isBaseTelemetryEnabled, trackBaseEvents } from "./telemetry.mjs"; var FILE_MARKERS = [ { file: "next.config.js", skills: ["nextjs", "turbopack"] }, { file: "next.config.mjs", skills: ["nextjs", "turbopack"] }, @@ -418,9 +419,7 @@ async function main() { const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveHookProjectRoot(hookInput); - const previousVercelProjectLinkState = sessionId ? readSessionVercelProjectLinkState(sessionId) : null; - const vercelProjectLink = resolveVercelProjectLink(projectRoot); - const vercelProjectLinkResolvedAt = Date.now(); + const telemetryEnabled = isBaseTelemetryEnabled(); logBrokenSkillFrontmatterSummary(); const greenfield = checkGreenfield(projectRoot); const cliStatus = checkVercelCli(); @@ -480,10 +479,11 @@ async function main() { }); } } - if (sessionId) { - const deviceId = getOrCreateDeviceId(); + if (sessionId && telemetryEnabled) { + const previousVercelProjectLinkState = readSessionVercelProjectLinkState(sessionId); + const vercelProjectLink = resolveVercelProjectLink(projectRoot); const telemetryEntries = [ - { key: "session:device_id", value: deviceId }, + { key: "session:device_id", value: getOrCreateDeviceId() }, { key: "session:platform", value: process.platform }, { key: "session:likely_skills", value: likelySkills.join(",") }, { key: "session:greenfield", value: String(greenfield !== null) }, @@ -502,21 +502,13 @@ async function main() { ); } const trackedBaseTelemetry = await trackBaseEvents(sessionId, telemetryEntries).catch(() => false); - const nextVercelProjectLinkState = { - lastResolvedAt: vercelProjectLinkResolvedAt, - lastResolvedRoot: projectRoot, - lastSentProjectId: trackedBaseTelemetry ? void 0 : previousVercelProjectLinkState?.lastSentProjectId, - lastSentOrgId: trackedBaseTelemetry ? void 0 : previousVercelProjectLinkState?.lastSentOrgId - }; - if (vercelProjectLink) { - nextVercelProjectLinkState.projectId = vercelProjectLink.projectId; - nextVercelProjectLinkState.orgId = vercelProjectLink.orgId; - if (trackedBaseTelemetry) { - nextVercelProjectLinkState.lastSentProjectId = vercelProjectLink.projectId; - nextVercelProjectLinkState.lastSentOrgId = vercelProjectLink.orgId; - } - } - writeSessionVercelProjectLinkState(sessionId, nextVercelProjectLinkState); + writeSessionVercelProjectLinkState(sessionId, buildSessionVercelProjectLinkState({ + previousState: previousVercelProjectLinkState, + projectRoot, + resolvedAt: Date.now(), + nextLink: vercelProjectLink, + trackedTelemetry: trackedBaseTelemetry + })); } if (cursorOutput) { process.stdout.write(cursorOutput); diff --git a/hooks/src/hook-env.mts b/hooks/src/hook-env.mts index 048b8c1..05b3dd9 100644 --- a/hooks/src/hook-env.mts +++ b/hooks/src/hook-env.mts @@ -552,6 +552,40 @@ export function writeSessionVercelProjectLinkState( writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); } +export function buildSessionVercelProjectLinkState(args: { + previousState: SessionVercelProjectLinkState | null; + projectRoot: string; + resolvedAt: number; + nextLink: VercelProjectLink | null; + trackedTelemetry: boolean; +}): SessionVercelProjectLinkState { + const nextState: SessionVercelProjectLinkState = { + lastResolvedAt: args.resolvedAt, + lastResolvedRoot: args.projectRoot, + }; + + if (args.nextLink) { + nextState.projectId = args.nextLink.projectId; + nextState.orgId = args.nextLink.orgId; + } + + const lastSentProjectId = args.trackedTelemetry + ? args.nextLink?.projectId + : args.previousState?.lastSentProjectId; + const lastSentOrgId = args.trackedTelemetry + ? args.nextLink?.orgId + : args.previousState?.lastSentOrgId; + + if (lastSentProjectId !== undefined) { + nextState.lastSentProjectId = lastSentProjectId; + } + if (lastSentOrgId !== undefined) { + nextState.lastSentOrgId = lastSentOrgId; + } + + return nextState; +} + function hasUnsentSessionVercelProjectLink(state: SessionVercelProjectLinkState | null): boolean { if (!state) { return false; diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index 42b9834..b665792 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -30,6 +30,7 @@ import { type HookPlatform, } from "./compat.mjs"; import { + buildSessionVercelProjectLinkState, pluginRoot, profileCachePath, readSessionVercelProjectLinkState, @@ -38,11 +39,10 @@ import { safeReadJson, writeSessionFile, writeSessionVercelProjectLinkState, - type SessionVercelProjectLinkState, } from "./hook-env.mjs"; import { createLogger, logCaughtError, type Logger } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; -import { trackBaseEvents, getOrCreateDeviceId } from "./telemetry.mjs"; +import { getOrCreateDeviceId, isBaseTelemetryEnabled, trackBaseEvents } from "./telemetry.mjs"; // --------------------------------------------------------------------------- // Types @@ -629,11 +629,7 @@ async function main(): Promise { const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveHookProjectRoot(hookInput as Record | null); - const previousVercelProjectLinkState = sessionId - ? readSessionVercelProjectLinkState(sessionId) - : null; - const vercelProjectLink = resolveVercelProjectLink(projectRoot); - const vercelProjectLinkResolvedAt = Date.now(); + const telemetryEnabled = isBaseTelemetryEnabled(); logBrokenSkillFrontmatterSummary(); @@ -712,10 +708,11 @@ async function main(): Promise { } // Base telemetry — enabled by default unless VERCEL_PLUGIN_TELEMETRY=off - if (sessionId) { - const deviceId = getOrCreateDeviceId(); + if (sessionId && telemetryEnabled) { + const previousVercelProjectLinkState = readSessionVercelProjectLinkState(sessionId); + const vercelProjectLink = resolveVercelProjectLink(projectRoot); const telemetryEntries: Array<{ key: string; value: string }> = [ - { key: "session:device_id", value: deviceId }, + { key: "session:device_id", value: getOrCreateDeviceId() }, { key: "session:platform", value: process.platform }, { key: "session:likely_skills", value: likelySkills.join(",") }, { key: "session:greenfield", value: String(greenfield !== null) }, @@ -739,23 +736,13 @@ async function main(): Promise { } const trackedBaseTelemetry = await trackBaseEvents(sessionId, telemetryEntries).catch(() => false); - const nextVercelProjectLinkState: SessionVercelProjectLinkState = { - lastResolvedAt: vercelProjectLinkResolvedAt, - lastResolvedRoot: projectRoot, - lastSentProjectId: trackedBaseTelemetry ? undefined : previousVercelProjectLinkState?.lastSentProjectId, - lastSentOrgId: trackedBaseTelemetry ? undefined : previousVercelProjectLinkState?.lastSentOrgId, - }; - - if (vercelProjectLink) { - nextVercelProjectLinkState.projectId = vercelProjectLink.projectId; - nextVercelProjectLinkState.orgId = vercelProjectLink.orgId; - if (trackedBaseTelemetry) { - nextVercelProjectLinkState.lastSentProjectId = vercelProjectLink.projectId; - nextVercelProjectLinkState.lastSentOrgId = vercelProjectLink.orgId; - } - } - - writeSessionVercelProjectLinkState(sessionId, nextVercelProjectLinkState); + writeSessionVercelProjectLinkState(sessionId, buildSessionVercelProjectLinkState({ + previousState: previousVercelProjectLinkState, + projectRoot, + resolvedAt: Date.now(), + nextLink: vercelProjectLink, + trackedTelemetry: trackedBaseTelemetry, + })); } if (cursorOutput) { diff --git a/hooks/src/user-prompt-submit-telemetry.mts b/hooks/src/user-prompt-submit-telemetry.mts index b40b1bc..6ed1174 100644 --- a/hooks/src/user-prompt-submit-telemetry.mts +++ b/hooks/src/user-prompt-submit-telemetry.mts @@ -30,12 +30,12 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { + buildSessionVercelProjectLinkState, readSessionVercelProjectLinkState, resolveHookProjectRoot, resolveVercelProjectLink, shouldRefreshSessionVercelProjectLink, writeSessionVercelProjectLinkState, - type SessionVercelProjectLinkState, } from "./hook-env.mjs"; import { getTelemetryOverride, isPromptTelemetryEnabled, trackBaseEvents, trackEvents } from "./telemetry.mjs"; @@ -95,23 +95,13 @@ async function maybeTrackVercelProjectLink(sessionId: string, projectRoot: strin ? await trackBaseEvents(sessionId, telemetryEntries).catch(() => false) : false; - const nextState: SessionVercelProjectLinkState = { - lastResolvedAt: now, - lastResolvedRoot: projectRoot, - lastSentProjectId: trackedLink ? undefined : previousState?.lastSentProjectId, - lastSentOrgId: trackedLink ? undefined : previousState?.lastSentOrgId, - }; - - if (nextLink) { - nextState.projectId = nextLink.projectId; - nextState.orgId = nextLink.orgId; - if (trackedLink) { - nextState.lastSentProjectId = nextLink.projectId; - nextState.lastSentOrgId = nextLink.orgId; - } - } - - writeSessionVercelProjectLinkState(sessionId, nextState); + writeSessionVercelProjectLinkState(sessionId, buildSessionVercelProjectLinkState({ + previousState, + projectRoot, + resolvedAt: now, + nextLink, + trackedTelemetry: trackedLink, + })); } async function main(): Promise { diff --git a/hooks/user-prompt-submit-telemetry.mjs b/hooks/user-prompt-submit-telemetry.mjs index 90623c9..27c6a29 100755 --- a/hooks/user-prompt-submit-telemetry.mjs +++ b/hooks/user-prompt-submit-telemetry.mjs @@ -5,6 +5,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; import { homedir, tmpdir } from "os"; import { join, dirname } from "path"; import { + buildSessionVercelProjectLinkState, readSessionVercelProjectLinkState, resolveHookProjectRoot, resolveVercelProjectLink, @@ -52,21 +53,13 @@ async function maybeTrackVercelProjectLink(sessionId, projectRoot) { ); } const trackedLink = telemetryEntries.length > 0 ? await trackBaseEvents(sessionId, telemetryEntries).catch(() => false) : false; - const nextState = { - lastResolvedAt: now, - lastResolvedRoot: projectRoot, - lastSentProjectId: trackedLink ? void 0 : previousState?.lastSentProjectId, - lastSentOrgId: trackedLink ? void 0 : previousState?.lastSentOrgId - }; - if (nextLink) { - nextState.projectId = nextLink.projectId; - nextState.orgId = nextLink.orgId; - if (trackedLink) { - nextState.lastSentProjectId = nextLink.projectId; - nextState.lastSentOrgId = nextLink.orgId; - } - } - writeSessionVercelProjectLinkState(sessionId, nextState); + writeSessionVercelProjectLinkState(sessionId, buildSessionVercelProjectLinkState({ + previousState, + projectRoot, + resolvedAt: now, + nextLink, + trackedTelemetry: trackedLink + })); } async function main() { const input = parseStdin(); diff --git a/tests/session-start-profiler.test.ts b/tests/session-start-profiler.test.ts index 5314992..6910f8c 100644 --- a/tests/session-start-profiler.test.ts +++ b/tests/session-start-profiler.test.ts @@ -680,6 +680,27 @@ describe("session-start-profiler", () => { expect(readVercelProjectLinkState()?.lastResolvedAt).toEqual(expect.any(Number)); }); + test("skips linked project resolution and state writes when base telemetry is disabled", async () => { + const projectDir = join(tempDir, "telemetry-off-linked-project"); + mkdirSync(join(projectDir, ".vercel"), { recursive: true }); + writeFileSync( + join(projectDir, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_linked", orgId: "team_linked" }), + ); + + const result = await runProfilerWithCapture({ + env: { + CLAUDE_ENV_FILE: envFile, + CLAUDE_PROJECT_ROOT: projectDir, + VERCEL_PLUGIN_TELEMETRY: "off", + }, + }); + + expect(result.code).toBe(0); + expect(result.requests).toHaveLength(0); + expect(readVercelProjectLinkState()).toBeNull(); + }); + test("prefers payload cwd over stale env roots when resolving linked Vercel project telemetry", async () => { const staleRoot = join(tempDir, "stale-linked-root"); const currentRoot = join(tempDir, "current-linked-root"); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index e0b8f82..c5114b2 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { + buildSessionVercelProjectLinkState, parseSessionVercelProjectLinkState, readSessionVercelProjectLinkState, resolveHookProjectRoot, @@ -267,6 +268,58 @@ describe("Vercel project link refresh", () => { }); }); + test("builds next project link state without two-phase lastSent mutations", () => { + const previousState = { + lastResolvedAt: 100, + lastResolvedRoot: "/repo/apps/old", + projectId: "prj_old", + orgId: "team_old", + lastSentProjectId: "prj_old", + lastSentOrgId: "team_old", + }; + + expect(buildSessionVercelProjectLinkState({ + previousState, + projectRoot: "/repo/apps/web", + resolvedAt: 200, + nextLink: { projectId: "prj_current", orgId: "team_current", source: "project.json" }, + trackedTelemetry: false, + })).toEqual({ + lastResolvedAt: 200, + lastResolvedRoot: "/repo/apps/web", + projectId: "prj_current", + orgId: "team_current", + lastSentProjectId: "prj_old", + lastSentOrgId: "team_old", + }); + + expect(buildSessionVercelProjectLinkState({ + previousState, + projectRoot: "/repo/apps/web", + resolvedAt: 201, + nextLink: { projectId: "prj_current", orgId: "team_current", source: "repo.json" }, + trackedTelemetry: true, + })).toEqual({ + lastResolvedAt: 201, + lastResolvedRoot: "/repo/apps/web", + projectId: "prj_current", + orgId: "team_current", + lastSentProjectId: "prj_current", + lastSentOrgId: "team_current", + }); + + expect(buildSessionVercelProjectLinkState({ + previousState, + projectRoot: "/repo/apps/plain", + resolvedAt: 202, + nextLink: null, + trackedTelemetry: true, + })).toEqual({ + lastResolvedAt: 202, + lastResolvedRoot: "/repo/apps/plain", + }); + }); + test("refreshes when the cached link is missing, root changed, unsent, or at least an hour old", () => { const now = Date.now(); const currentRoot = "/repo/apps/web"; From b5e9ad6fe7daeb5504ca3d738a7797ac3f7260ed Mon Sep 17 00:00:00 2001 From: Grappeggia Date: Wed, 8 Apr 2026 07:28:28 -0700 Subject: [PATCH 7/9] narrow linked project telemetry to session start Only read .vercel/project.json during session start and include the linked project and org IDs in the existing base telemetry batch. Drop the prompt refresh and session state lifecycle changes so the PR stays focused on the minimum telemetry addition. --- hooks/hook-env.mjs | 188 +--------- hooks/session-hooks-platform-compat.test.ts | 2 + hooks/session-start-profiler.mjs | 95 ++--- hooks/src/hook-env.mts | 288 +-------------- hooks/src/session-start-profiler.mts | 121 +++--- hooks/src/telemetry.mts | 21 +- hooks/src/user-prompt-submit-telemetry.mts | 64 +--- hooks/telemetry.mjs | 14 +- hooks/user-prompt-submit-telemetry.mjs | 44 +-- tests/session-end-cleanup.test.ts | 25 -- tests/session-start-profiler.test.ts | 381 +++---------------- tests/session-start-seen-skills.test.ts | 22 +- tests/telemetry.test.ts | 387 +------------------- 13 files changed, 203 insertions(+), 1449 deletions(-) diff --git a/hooks/hook-env.mjs b/hooks/hook-env.mjs index d629d74..8e882ea 100644 --- a/hooks/hook-env.mjs +++ b/hooks/hook-env.mjs @@ -3,7 +3,6 @@ import { createHash, randomUUID } from "crypto"; import { appendFileSync, closeSync, - existsSync, mkdirSync, openSync, readFileSync, @@ -12,7 +11,7 @@ import { writeFileSync } from "fs"; import { homedir, tmpdir } from "os"; -import { dirname, join, relative, resolve, sep } from "path"; +import { dirname, join, resolve, sep } from "path"; import { fileURLToPath } from "url"; import { createLogger, logCaughtError } from "./logger.mjs"; var log = createLogger(); @@ -198,204 +197,21 @@ function safeReadJson(path) { return null; } } -var SESSION_VERCEL_PROJECT_LINK_KIND = "vercel-project-link"; -function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} -function readJsonIfExists(path) { - if (!existsSync(path)) return null; - try { - return JSON.parse(readFileSync(path, "utf-8")); - } catch { - return null; - } -} -function asNonEmptyString(value) { - return typeof value === "string" && value.trim() !== "" ? value : null; -} -function resolveHookProjectRoot(input, env = process.env) { - const workspaceRoot = input && Array.isArray(input.workspace_roots) ? input.workspace_roots.find((entry) => typeof entry === "string" && entry.trim() !== "") : null; - const cwd = input && typeof input.cwd === "string" && input.cwd.trim() !== "" ? input.cwd : null; - return cwd ?? (typeof workspaceRoot === "string" ? workspaceRoot : null) ?? asNonEmptyString(env.CURSOR_PROJECT_DIR) ?? asNonEmptyString(env.CLAUDE_PROJECT_ROOT) ?? asNonEmptyString(env.CLAUDE_PROJECT_DIR) ?? process.cwd(); -} -function normalizeRepoPath(pathValue) { - const normalized = pathValue.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, ""); - return normalized === "" ? "." : normalized; -} -function pathDepth(pathValue) { - return pathValue === "." ? 0 : pathValue.split("/").length; -} -function matchesRepoProjectDirectory(projectDirectory, currentPath) { - if (projectDirectory === ".") { - return true; - } - return currentPath === projectDirectory || currentPath.startsWith(`${projectDirectory}/`); -} -function resolveProjectJsonLink(dir) { - const raw = readJsonIfExists(join(dir, ".vercel", "project.json")); - if (!isRecord(raw)) return null; - const projectId = asNonEmptyString(raw.projectId); - const orgId = asNonEmptyString(raw.orgId); - if (!projectId || !orgId) return null; - return { - projectId, - orgId, - source: "project.json" - }; -} -function resolveRepoJsonLink(repoRoot, startPath) { - const raw = readJsonIfExists(join(repoRoot, ".vercel", "repo.json")); - if (!isRecord(raw) || !Array.isArray(raw.projects)) { - return null; - } - const repoOrgId = asNonEmptyString(raw.orgId); - const currentPath = normalizeRepoPath(relative(repoRoot, startPath)); - const candidates = raw.projects.filter(isRecord).map((project) => { - const projectId = asNonEmptyString(project.id); - const orgId = asNonEmptyString(project.orgId) ?? repoOrgId; - const directory = normalizeRepoPath(asNonEmptyString(project.directory) ?? "."); - if (!projectId || !orgId) { - return null; - } - return { - directory, - projectId, - orgId - }; - }).filter((candidate) => candidate !== null).filter((candidate) => matchesRepoProjectDirectory(candidate.directory, currentPath)).sort((left, right) => pathDepth(right.directory) - pathDepth(left.directory)); - if (candidates.length === 0) { - return null; - } - const deepestDepth = pathDepth(candidates[0].directory); - const deepestCandidates = candidates.filter((candidate) => pathDepth(candidate.directory) === deepestDepth); - if (deepestCandidates.length !== 1) { - return null; - } - return { - projectId: deepestCandidates[0].projectId, - orgId: deepestCandidates[0].orgId, - source: "repo.json" - }; -} -function resolveVercelProjectLink(startPath) { - const resolvedStartPath = resolve(startPath); - let current = resolvedStartPath; - while (true) { - const projectLink = resolveProjectJsonLink(current); - if (projectLink) { - return projectLink; - } - const repoJsonPath = join(current, ".vercel", "repo.json"); - if (existsSync(repoJsonPath)) { - const repoLink = resolveRepoJsonLink(current, resolvedStartPath); - if (repoLink) { - return repoLink; - } - } - const parent = dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} -function parseSessionVercelProjectLinkState(raw) { - if (raw.trim() === "") return null; - try { - const parsed = JSON.parse(raw); - if (!isRecord(parsed)) return null; - const lastResolvedAt = parsed.lastResolvedAt; - if (typeof lastResolvedAt !== "number" || !Number.isFinite(lastResolvedAt)) { - return null; - } - const state = { lastResolvedAt }; - const lastResolvedRoot = asNonEmptyString(parsed.lastResolvedRoot); - const projectId = asNonEmptyString(parsed.projectId); - const orgId = asNonEmptyString(parsed.orgId); - const lastSentProjectId = asNonEmptyString(parsed.lastSentProjectId); - const lastSentOrgId = asNonEmptyString(parsed.lastSentOrgId); - if (lastResolvedRoot) { - state.lastResolvedRoot = lastResolvedRoot; - } - if (projectId) { - state.projectId = projectId; - } - if (orgId) { - state.orgId = orgId; - } - if (lastSentProjectId) { - state.lastSentProjectId = lastSentProjectId; - } - if (lastSentOrgId) { - state.lastSentOrgId = lastSentOrgId; - } - return state; - } catch { - return null; - } -} -function readSessionVercelProjectLinkState(sessionId) { - try { - const raw = readFileSync(dedupFilePath(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND), "utf-8"); - return parseSessionVercelProjectLinkState(raw); - } catch { - return null; - } -} -function writeSessionVercelProjectLinkState(sessionId, state) { - writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); -} -function buildSessionVercelProjectLinkState(args) { - const nextState = { - lastResolvedAt: args.resolvedAt, - lastResolvedRoot: args.projectRoot - }; - if (args.nextLink) { - nextState.projectId = args.nextLink.projectId; - nextState.orgId = args.nextLink.orgId; - } - const lastSentProjectId = args.trackedTelemetry ? args.nextLink?.projectId : args.previousState?.lastSentProjectId; - const lastSentOrgId = args.trackedTelemetry ? args.nextLink?.orgId : args.previousState?.lastSentOrgId; - if (lastSentProjectId !== void 0) { - nextState.lastSentProjectId = lastSentProjectId; - } - if (lastSentOrgId !== void 0) { - nextState.lastSentOrgId = lastSentOrgId; - } - return nextState; -} -function hasUnsentSessionVercelProjectLink(state) { - if (!state) { - return false; - } - return (state.projectId ?? null) !== (state.lastSentProjectId ?? null) || (state.orgId ?? null) !== (state.lastSentOrgId ?? null); -} -function shouldRefreshSessionVercelProjectLink(state, currentProjectRoot, now, refreshMs) { - return !state || state.lastResolvedRoot !== currentProjectRoot || hasUnsentSessionVercelProjectLink(state) || now - state.lastResolvedAt >= refreshMs; -} export { - SESSION_VERCEL_PROJECT_LINK_KIND, appendAuditLog, - buildSessionVercelProjectLinkState, dedupClaimDirPath, dedupFilePath, generateVerificationId, getDedupScopeId, listSessionKeys, - parseSessionVercelProjectLinkState, pluginRoot, profileCachePath, readSessionFile, - readSessionVercelProjectLinkState, removeAllSessionDedupArtifacts, removeSessionClaimDir, - resolveHookProjectRoot, - resolveVercelProjectLink, safeReadFile, safeReadJson, - shouldRefreshSessionVercelProjectLink, syncSessionFileFromClaims, tryClaimSessionKey, - writeSessionFile, - writeSessionVercelProjectLinkState + writeSessionFile }; diff --git a/hooks/session-hooks-platform-compat.test.ts b/hooks/session-hooks-platform-compat.test.ts index e8e292b..318ea94 100644 --- a/hooks/session-hooks-platform-compat.test.ts +++ b/hooks/session-hooks-platform-compat.test.ts @@ -52,6 +52,7 @@ describe("session hook platform compatibility", () => { {}, ); const envVars = buildSessionStartProfilerEnvVars({ + agentBrowserAvailable: true, greenfield: true, likelySkills: ["ai-sdk", "nextjs"], setupSignals: { @@ -67,6 +68,7 @@ describe("session hook platform compatibility", () => { ); expect(JSON.parse(formatSessionStartProfilerCursorOutput(envVars, ["profile ready"]))).toEqual({ env: { + VERCEL_PLUGIN_AGENT_BROWSER_AVAILABLE: "1", VERCEL_PLUGIN_GREENFIELD: "true", VERCEL_PLUGIN_LIKELY_SKILLS: "ai-sdk,nextjs", VERCEL_PLUGIN_BOOTSTRAP_HINTS: "greenfield", diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index 6647481..bb005ea 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -15,20 +15,10 @@ import { normalizeInput, setSessionEnv } from "./compat.mjs"; -import { - buildSessionVercelProjectLinkState, - pluginRoot, - profileCachePath, - readSessionVercelProjectLinkState, - resolveHookProjectRoot, - resolveVercelProjectLink, - safeReadJson, - writeSessionFile, - writeSessionVercelProjectLinkState -} from "./hook-env.mjs"; +import { pluginRoot, profileCachePath, safeReadJson, writeSessionFile } from "./hook-env.mjs"; import { createLogger, logCaughtError } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; -import { getOrCreateDeviceId, isBaseTelemetryEnabled, trackBaseEvents } from "./telemetry.mjs"; +import { trackBaseEvents, getOrCreateDeviceId } from "./telemetry.mjs"; var FILE_MARKERS = [ { file: "next.config.js", skills: ["nextjs", "turbopack"] }, { file: "next.config.mjs", skills: ["nextjs", "turbopack"] }, @@ -307,6 +297,39 @@ function checkVercelCli() { const needsUpdate = versionComparison === null ? !!(currentVersion && latestVersion && currentVersion !== latestVersion) : versionComparison < 0; return { installed: true, currentVersion, latestVersion, needsUpdate }; } +function readLinkedVercelProject(projectRoot) { + const projectJsonPath = join(projectRoot, ".vercel", "project.json"); + if (!existsSync(projectJsonPath)) { + return null; + } + const project = safeReadJson(projectJsonPath); + if (!project) { + return null; + } + const projectId = typeof project.projectId === "string" && project.projectId.trim() !== "" ? project.projectId : null; + const orgId = typeof project.orgId === "string" && project.orgId.trim() !== "" ? project.orgId : null; + if (!projectId || !orgId) { + return null; + } + return { projectId, orgId }; +} +function buildSessionStartTelemetryEntries(args) { + const entries = [ + { key: "session:device_id", value: args.deviceId }, + { key: "session:platform", value: process.platform }, + { key: "session:likely_skills", value: args.likelySkills.join(",") }, + { key: "session:greenfield", value: String(args.greenfield) }, + { key: "session:vercel_cli_installed", value: String(args.cliStatus.installed) }, + { key: "session:vercel_cli_version", value: args.cliStatus.currentVersion || "" } + ]; + if (args.vercelProjectLink) { + entries.push( + { key: "session:vercel_project_id", value: args.vercelProjectLink.projectId }, + { key: "session:vercel_org_id", value: args.vercelProjectLink.orgId } + ); + } + return entries; +} function parseSessionStartInput(raw) { try { if (!raw.trim()) return null; @@ -330,7 +353,7 @@ function normalizeSessionStartSessionId(input) { return sessionId || null; } function resolveSessionStartProjectRoot(env = process.env) { - return resolveHookProjectRoot(null, env); + return env.CLAUDE_PROJECT_ROOT ?? env.CURSOR_PROJECT_DIR ?? process.cwd(); } function collectBrokenSkillFrontmatterNames(files) { return [...new Set( @@ -418,8 +441,7 @@ async function main() { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); - const projectRoot = resolveHookProjectRoot(hookInput); - const telemetryEnabled = isBaseTelemetryEnabled(); + const projectRoot = resolveSessionStartProjectRoot(); logBrokenSkillFrontmatterSummary(); const greenfield = checkGreenfield(projectRoot); const cliStatus = checkVercelCli(); @@ -479,36 +501,17 @@ async function main() { }); } } - if (sessionId && telemetryEnabled) { - const previousVercelProjectLinkState = readSessionVercelProjectLinkState(sessionId); - const vercelProjectLink = resolveVercelProjectLink(projectRoot); - const telemetryEntries = [ - { key: "session:device_id", value: getOrCreateDeviceId() }, - { key: "session:platform", value: process.platform }, - { key: "session:likely_skills", value: likelySkills.join(",") }, - { key: "session:greenfield", value: String(greenfield !== null) }, - { key: "session:vercel_cli_installed", value: String(cliStatus.installed) }, - { key: "session:vercel_cli_version", value: cliStatus.currentVersion || "" } - ]; - if (vercelProjectLink) { - telemetryEntries.push( - { key: "session:vercel_project_id", value: vercelProjectLink.projectId }, - { key: "session:vercel_org_id", value: vercelProjectLink.orgId } - ); - } else if (previousVercelProjectLinkState?.lastSentProjectId !== void 0 || previousVercelProjectLinkState?.lastSentOrgId !== void 0) { - telemetryEntries.push( - { key: "session:vercel_project_id", value: "" }, - { key: "session:vercel_org_id", value: "" } - ); - } - const trackedBaseTelemetry = await trackBaseEvents(sessionId, telemetryEntries).catch(() => false); - writeSessionVercelProjectLinkState(sessionId, buildSessionVercelProjectLinkState({ - previousState: previousVercelProjectLinkState, - projectRoot, - resolvedAt: Date.now(), - nextLink: vercelProjectLink, - trackedTelemetry: trackedBaseTelemetry - })); + if (sessionId) { + const deviceId = getOrCreateDeviceId(); + const vercelProjectLink = readLinkedVercelProject(projectRoot); + await trackBaseEvents(sessionId, buildSessionStartTelemetryEntries({ + deviceId, + likelySkills, + greenfield: greenfield !== null, + cliStatus, + vercelProjectLink + })).catch(() => { + }); } if (cursorOutput) { process.stdout.write(cursorOutput); @@ -523,6 +526,7 @@ if (isSessionStartProfilerEntrypoint) { export { buildSessionStartProfilerEnvVars, buildSessionStartProfilerUserMessages, + buildSessionStartTelemetryEntries, checkGreenfield, detectSessionStartPlatform, formatSessionStartProfilerCursorOutput, @@ -531,5 +535,6 @@ export { parseSessionStartInput, profileBootstrapSignals, profileProject, + readLinkedVercelProject, resolveSessionStartProjectRoot }; diff --git a/hooks/src/hook-env.mts b/hooks/src/hook-env.mts index 05b3dd9..d0fe68f 100644 --- a/hooks/src/hook-env.mts +++ b/hooks/src/hook-env.mts @@ -10,7 +10,6 @@ import { createHash, randomUUID } from "node:crypto"; import { appendFileSync, closeSync, - existsSync, mkdirSync, openSync, readFileSync, @@ -19,7 +18,7 @@ import { writeFileSync, } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import { dirname, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { createLogger, logCaughtError, type Logger } from "./logger.mjs"; @@ -321,288 +320,3 @@ export function safeReadJson(path: string): T | null { return null; } } - -// --------------------------------------------------------------------------- -// Vercel project linkage helpers -// --------------------------------------------------------------------------- - -export const SESSION_VERCEL_PROJECT_LINK_KIND = "vercel-project-link"; - -export interface VercelProjectLink { - projectId: string; - orgId: string; - source: "project.json" | "repo.json"; -} - -export interface SessionVercelProjectLinkState { - lastResolvedAt: number; - lastResolvedRoot?: string; - projectId?: string; - orgId?: string; - lastSentProjectId?: string; - lastSentOrgId?: string; -} - -interface RepoProjectCandidate { - directory: string; - projectId: string; - orgId: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readJsonIfExists(path: string): unknown | null { - if (!existsSync(path)) return null; - - try { - return JSON.parse(readFileSync(path, "utf-8")); - } catch { - return null; - } -} - -function asNonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() !== "" ? value : null; -} - -export function resolveHookProjectRoot( - input: Record | null, - env: NodeJS.ProcessEnv = process.env, -): string { - const workspaceRoot = input && Array.isArray(input.workspace_roots) - ? input.workspace_roots.find((entry) => typeof entry === "string" && entry.trim() !== "") - : null; - const cwd = input && typeof input.cwd === "string" && input.cwd.trim() !== "" - ? input.cwd - : null; - - return cwd - ?? (typeof workspaceRoot === "string" ? workspaceRoot : null) - ?? asNonEmptyString(env.CURSOR_PROJECT_DIR) - ?? asNonEmptyString(env.CLAUDE_PROJECT_ROOT) - ?? asNonEmptyString(env.CLAUDE_PROJECT_DIR) - ?? process.cwd(); -} - -function normalizeRepoPath(pathValue: string): string { - const normalized = pathValue - .replaceAll("\\", "/") - .replace(/^\.\//, "") - .replace(/\/+$/, ""); - - return normalized === "" ? "." : normalized; -} - -function pathDepth(pathValue: string): number { - return pathValue === "." ? 0 : pathValue.split("/").length; -} - -function matchesRepoProjectDirectory(projectDirectory: string, currentPath: string): boolean { - if (projectDirectory === ".") { - return true; - } - - return currentPath === projectDirectory || currentPath.startsWith(`${projectDirectory}/`); -} - -function resolveProjectJsonLink(dir: string): VercelProjectLink | null { - const raw = readJsonIfExists(join(dir, ".vercel", "project.json")); - if (!isRecord(raw)) return null; - - const projectId = asNonEmptyString(raw.projectId); - const orgId = asNonEmptyString(raw.orgId); - if (!projectId || !orgId) return null; - - return { - projectId, - orgId, - source: "project.json", - }; -} - -function resolveRepoJsonLink(repoRoot: string, startPath: string): VercelProjectLink | null { - const raw = readJsonIfExists(join(repoRoot, ".vercel", "repo.json")); - if (!isRecord(raw) || !Array.isArray(raw.projects)) { - return null; - } - - const repoOrgId = asNonEmptyString(raw.orgId); - const currentPath = normalizeRepoPath(relative(repoRoot, startPath)); - const candidates: RepoProjectCandidate[] = raw.projects - .filter(isRecord) - .map((project) => { - const projectId = asNonEmptyString(project.id); - const orgId = asNonEmptyString(project.orgId) ?? repoOrgId; - const directory = normalizeRepoPath(asNonEmptyString(project.directory) ?? "."); - - if (!projectId || !orgId) { - return null; - } - - return { - directory, - projectId, - orgId, - }; - }) - .filter((candidate): candidate is RepoProjectCandidate => candidate !== null) - .filter((candidate) => matchesRepoProjectDirectory(candidate.directory, currentPath)) - .sort((left, right) => pathDepth(right.directory) - pathDepth(left.directory)); - - if (candidates.length === 0) { - return null; - } - - const deepestDepth = pathDepth(candidates[0].directory); - const deepestCandidates = candidates.filter((candidate) => pathDepth(candidate.directory) === deepestDepth); - if (deepestCandidates.length !== 1) { - return null; - } - - return { - projectId: deepestCandidates[0].projectId, - orgId: deepestCandidates[0].orgId, - source: "repo.json", - }; -} - -export function resolveVercelProjectLink(startPath: string): VercelProjectLink | null { - const resolvedStartPath = resolve(startPath); - let current = resolvedStartPath; - - while (true) { - const projectLink = resolveProjectJsonLink(current); - if (projectLink) { - return projectLink; - } - - const repoJsonPath = join(current, ".vercel", "repo.json"); - if (existsSync(repoJsonPath)) { - const repoLink = resolveRepoJsonLink(current, resolvedStartPath); - if (repoLink) { - return repoLink; - } - } - - const parent = dirname(current); - if (parent === current) { - return null; - } - - current = parent; - } -} - -export function parseSessionVercelProjectLinkState(raw: string): SessionVercelProjectLinkState | null { - if (raw.trim() === "") return null; - - try { - const parsed = JSON.parse(raw); - if (!isRecord(parsed)) return null; - - const lastResolvedAt = parsed.lastResolvedAt; - if (typeof lastResolvedAt !== "number" || !Number.isFinite(lastResolvedAt)) { - return null; - } - - const state: SessionVercelProjectLinkState = { lastResolvedAt }; - const lastResolvedRoot = asNonEmptyString(parsed.lastResolvedRoot); - const projectId = asNonEmptyString(parsed.projectId); - const orgId = asNonEmptyString(parsed.orgId); - const lastSentProjectId = asNonEmptyString(parsed.lastSentProjectId); - const lastSentOrgId = asNonEmptyString(parsed.lastSentOrgId); - - if (lastResolvedRoot) { - state.lastResolvedRoot = lastResolvedRoot; - } - if (projectId) { - state.projectId = projectId; - } - if (orgId) { - state.orgId = orgId; - } - if (lastSentProjectId) { - state.lastSentProjectId = lastSentProjectId; - } - if (lastSentOrgId) { - state.lastSentOrgId = lastSentOrgId; - } - - return state; - } catch { - return null; - } -} - -export function readSessionVercelProjectLinkState(sessionId: string): SessionVercelProjectLinkState | null { - try { - const raw = readFileSync(dedupFilePath(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND), "utf-8"); - return parseSessionVercelProjectLinkState(raw); - } catch { - return null; - } -} - -export function writeSessionVercelProjectLinkState( - sessionId: string, - state: SessionVercelProjectLinkState, -): void { - writeSessionFile(sessionId, SESSION_VERCEL_PROJECT_LINK_KIND, JSON.stringify(state)); -} - -export function buildSessionVercelProjectLinkState(args: { - previousState: SessionVercelProjectLinkState | null; - projectRoot: string; - resolvedAt: number; - nextLink: VercelProjectLink | null; - trackedTelemetry: boolean; -}): SessionVercelProjectLinkState { - const nextState: SessionVercelProjectLinkState = { - lastResolvedAt: args.resolvedAt, - lastResolvedRoot: args.projectRoot, - }; - - if (args.nextLink) { - nextState.projectId = args.nextLink.projectId; - nextState.orgId = args.nextLink.orgId; - } - - const lastSentProjectId = args.trackedTelemetry - ? args.nextLink?.projectId - : args.previousState?.lastSentProjectId; - const lastSentOrgId = args.trackedTelemetry - ? args.nextLink?.orgId - : args.previousState?.lastSentOrgId; - - if (lastSentProjectId !== undefined) { - nextState.lastSentProjectId = lastSentProjectId; - } - if (lastSentOrgId !== undefined) { - nextState.lastSentOrgId = lastSentOrgId; - } - - return nextState; -} - -function hasUnsentSessionVercelProjectLink(state: SessionVercelProjectLinkState | null): boolean { - if (!state) { - return false; - } - - return (state.projectId ?? null) !== (state.lastSentProjectId ?? null) - || (state.orgId ?? null) !== (state.lastSentOrgId ?? null); -} - -export function shouldRefreshSessionVercelProjectLink( - state: SessionVercelProjectLinkState | null, - currentProjectRoot: string, - now: number, - refreshMs: number, -): boolean { - return !state - || state.lastResolvedRoot !== currentProjectRoot - || hasUnsentSessionVercelProjectLink(state) - || now - state.lastResolvedAt >= refreshMs; -} diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index b665792..403a593 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -29,20 +29,10 @@ import { setSessionEnv, type HookPlatform, } from "./compat.mjs"; -import { - buildSessionVercelProjectLinkState, - pluginRoot, - profileCachePath, - readSessionVercelProjectLinkState, - resolveHookProjectRoot, - resolveVercelProjectLink, - safeReadJson, - writeSessionFile, - writeSessionVercelProjectLinkState, -} from "./hook-env.mjs"; +import { pluginRoot, profileCachePath, safeReadJson, writeSessionFile } from "./hook-env.mjs"; import { createLogger, logCaughtError, type Logger } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; -import { getOrCreateDeviceId, isBaseTelemetryEnabled, trackBaseEvents } from "./telemetry.mjs"; +import { trackBaseEvents, getOrCreateDeviceId } from "./telemetry.mjs"; // --------------------------------------------------------------------------- // Types @@ -70,6 +60,11 @@ interface GreenfieldResult { entries: string[]; } +interface VercelProjectLink { + projectId: string; + orgId: string; +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -466,6 +461,58 @@ function checkVercelCli(): VercelCliStatus { return { installed: true, currentVersion, latestVersion, needsUpdate }; } +export function readLinkedVercelProject(projectRoot: string): VercelProjectLink | null { + const projectJsonPath = join(projectRoot, ".vercel", "project.json"); + if (!existsSync(projectJsonPath)) { + return null; + } + + const project = safeReadJson>(projectJsonPath); + if (!project) { + return null; + } + + const projectId = typeof project.projectId === "string" && project.projectId.trim() !== "" + ? project.projectId + : null; + const orgId = typeof project.orgId === "string" && project.orgId.trim() !== "" + ? project.orgId + : null; + + if (!projectId || !orgId) { + return null; + } + + return { projectId, orgId }; +} + +export function buildSessionStartTelemetryEntries(args: { + deviceId: string; + likelySkills: string[]; + greenfield: boolean; + cliStatus: VercelCliStatus; + vercelProjectLink?: VercelProjectLink | null; +}): Array<{ key: string; value: string }> { + const entries = [ + { key: "session:device_id", value: args.deviceId }, + { key: "session:platform", value: process.platform }, + { key: "session:likely_skills", value: args.likelySkills.join(",") }, + { key: "session:greenfield", value: String(args.greenfield) }, + { key: "session:vercel_cli_installed", value: String(args.cliStatus.installed) }, + { key: "session:vercel_cli_version", value: args.cliStatus.currentVersion || "" }, + ]; + + if (args.vercelProjectLink) { + entries.push( + { key: "session:vercel_project_id", value: args.vercelProjectLink.projectId }, + { key: "session:vercel_org_id", value: args.vercelProjectLink.orgId }, + ); + } + + return entries; +} + + // --------------------------------------------------------------------------- // Main entry point — profile the project and write env vars. // --------------------------------------------------------------------------- @@ -511,7 +558,7 @@ export function normalizeSessionStartSessionId(input: SessionStartInput | null): } export function resolveSessionStartProjectRoot(env: NodeJS.ProcessEnv = process.env): string { - return resolveHookProjectRoot(null, env); + return env.CLAUDE_PROJECT_ROOT ?? env.CURSOR_PROJECT_DIR ?? process.cwd(); } function collectBrokenSkillFrontmatterNames(files: string[]): string[] { @@ -628,8 +675,7 @@ async function main(): Promise { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); - const projectRoot = resolveHookProjectRoot(hookInput as Record | null); - const telemetryEnabled = isBaseTelemetryEnabled(); + const projectRoot = resolveSessionStartProjectRoot(); logBrokenSkillFrontmatterSummary(); @@ -708,41 +754,16 @@ async function main(): Promise { } // Base telemetry — enabled by default unless VERCEL_PLUGIN_TELEMETRY=off - if (sessionId && telemetryEnabled) { - const previousVercelProjectLinkState = readSessionVercelProjectLinkState(sessionId); - const vercelProjectLink = resolveVercelProjectLink(projectRoot); - const telemetryEntries: Array<{ key: string; value: string }> = [ - { key: "session:device_id", value: getOrCreateDeviceId() }, - { key: "session:platform", value: process.platform }, - { key: "session:likely_skills", value: likelySkills.join(",") }, - { key: "session:greenfield", value: String(greenfield !== null) }, - { key: "session:vercel_cli_installed", value: String(cliStatus.installed) }, - { key: "session:vercel_cli_version", value: cliStatus.currentVersion || "" }, - ]; - - if (vercelProjectLink) { - telemetryEntries.push( - { key: "session:vercel_project_id", value: vercelProjectLink.projectId }, - { key: "session:vercel_org_id", value: vercelProjectLink.orgId }, - ); - } else if ( - previousVercelProjectLinkState?.lastSentProjectId !== undefined - || previousVercelProjectLinkState?.lastSentOrgId !== undefined - ) { - telemetryEntries.push( - { key: "session:vercel_project_id", value: "" }, - { key: "session:vercel_org_id", value: "" }, - ); - } - - const trackedBaseTelemetry = await trackBaseEvents(sessionId, telemetryEntries).catch(() => false); - writeSessionVercelProjectLinkState(sessionId, buildSessionVercelProjectLinkState({ - previousState: previousVercelProjectLinkState, - projectRoot, - resolvedAt: Date.now(), - nextLink: vercelProjectLink, - trackedTelemetry: trackedBaseTelemetry, - })); + if (sessionId) { + const deviceId = getOrCreateDeviceId(); + const vercelProjectLink = readLinkedVercelProject(projectRoot); + await trackBaseEvents(sessionId, buildSessionStartTelemetryEntries({ + deviceId, + likelySkills, + greenfield: greenfield !== null, + cliStatus, + vercelProjectLink, + })).catch(() => {}); } if (cursorOutput) { diff --git a/hooks/src/telemetry.mts b/hooks/src/telemetry.mts index d09e6e4..9f4a839 100644 --- a/hooks/src/telemetry.mts +++ b/hooks/src/telemetry.mts @@ -26,13 +26,13 @@ function truncateValue(value: string): string { return truncated + TRUNCATION_SUFFIX; } -async function send(sessionId: string, events: TelemetryEvent[]): Promise { - if (events.length === 0) return false; +async function send(sessionId: string, events: TelemetryEvent[]): Promise { + if (events.length === 0) return; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); try { - const response = await fetch(BRIDGE_ENDPOINT, { + await fetch(BRIDGE_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", @@ -42,9 +42,8 @@ async function send(sessionId: string, events: TelemetryEvent[]): Promise { - if (!isBaseTelemetryEnabled()) return false; +export async function trackBaseEvent(sessionId: string, key: string, value: string): Promise { + if (!isBaseTelemetryEnabled()) return; const event: TelemetryEvent = { id: randomUUID(), @@ -130,14 +129,14 @@ export async function trackBaseEvent(sessionId: string, key: string, value: stri value: truncateValue(value), }; - return send(sessionId, [event]); + await send(sessionId, [event]); } export async function trackBaseEvents( sessionId: string, entries: Array<{ key: string; value: string }>, -): Promise { - if (!isBaseTelemetryEnabled() || entries.length === 0) return false; +): Promise { + if (!isBaseTelemetryEnabled() || entries.length === 0) return; const now = Date.now(); const events: TelemetryEvent[] = entries.map((entry) => ({ @@ -147,7 +146,7 @@ export async function trackBaseEvents( value: truncateValue(entry.value), })); - return send(sessionId, events); + await send(sessionId, events); } // --------------------------------------------------------------------------- diff --git a/hooks/src/user-prompt-submit-telemetry.mts b/hooks/src/user-prompt-submit-telemetry.mts index 6ed1174..997b58a 100644 --- a/hooks/src/user-prompt-submit-telemetry.mts +++ b/hooks/src/user-prompt-submit-telemetry.mts @@ -8,11 +8,7 @@ * when prompt telemetry is enabled. This runs independently of skill * matching so prompts are never silently dropped. * - * 2. Refresh linked Vercel project metadata no more than once per hour. - * This is best-effort and only re-emits telemetry when the linked - * project/org IDs changed or were previously missing. - * - * 3. On the first message of a session where the user hasn't recorded a + * 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". @@ -29,19 +25,10 @@ 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 { - buildSessionVercelProjectLinkState, - readSessionVercelProjectLinkState, - resolveHookProjectRoot, - resolveVercelProjectLink, - shouldRefreshSessionVercelProjectLink, - writeSessionVercelProjectLinkState, -} from "./hook-env.mjs"; -import { getTelemetryOverride, isPromptTelemetryEnabled, trackBaseEvents, trackEvents } from "./telemetry.mjs"; +import { getTelemetryOverride, isPromptTelemetryEnabled, trackEvents } from "./telemetry.mjs"; const PREF_PATH = join(homedir(), ".claude", "vercel-plugin-telemetry-preference"); const MIN_PROMPT_LENGTH = 10; -const VERCEL_PROJECT_LINK_REFRESH_MS = 60 * 60 * 1000; function parseStdin(): Record | null { try { @@ -61,59 +48,12 @@ function resolvePrompt(input: Record): string { return (input.prompt as string) || (input.message as string) || ""; } -async function maybeTrackVercelProjectLink(sessionId: string, projectRoot: string): Promise { - const now = Date.now(); - const previousState = readSessionVercelProjectLinkState(sessionId); - if (!shouldRefreshSessionVercelProjectLink(previousState, projectRoot, now, VERCEL_PROJECT_LINK_REFRESH_MS)) { - return; - } - - const nextLink = resolveVercelProjectLink(projectRoot); - const telemetryEntries: Array<{ key: string; value: string }> = []; - - if (nextLink) { - if ( - previousState?.lastSentProjectId !== nextLink.projectId - || previousState?.lastSentOrgId !== nextLink.orgId - ) { - telemetryEntries.push( - { key: "session:vercel_project_id", value: nextLink.projectId }, - { key: "session:vercel_org_id", value: nextLink.orgId }, - ); - } - } else if ( - previousState?.lastSentProjectId !== undefined - || previousState?.lastSentOrgId !== undefined - ) { - telemetryEntries.push( - { key: "session:vercel_project_id", value: "" }, - { key: "session:vercel_org_id", value: "" }, - ); - } - - const trackedLink = telemetryEntries.length > 0 - ? await trackBaseEvents(sessionId, telemetryEntries).catch(() => false) - : false; - - writeSessionVercelProjectLinkState(sessionId, buildSessionVercelProjectLinkState({ - previousState, - projectRoot, - resolvedAt: now, - nextLink, - trackedTelemetry: trackedLink, - })); -} - async function main(): Promise { const input = parseStdin(); const sessionId = input ? resolveSessionId(input) : ""; const prompt = input ? resolvePrompt(input) : ""; const telemetryOverride = getTelemetryOverride(); - if (telemetryOverride !== "off" && sessionId) { - await maybeTrackVercelProjectLink(sessionId, resolveHookProjectRoot(input)); - } - // Prompt text tracking — opt-in only if (isPromptTelemetryEnabled() && sessionId && prompt.length >= MIN_PROMPT_LENGTH) { await trackEvents(sessionId, [ diff --git a/hooks/telemetry.mjs b/hooks/telemetry.mjs index 2d73b4e..ecaf585 100644 --- a/hooks/telemetry.mjs +++ b/hooks/telemetry.mjs @@ -16,11 +16,11 @@ function truncateValue(value) { return truncated + TRUNCATION_SUFFIX; } async function send(sessionId, events) { - if (events.length === 0) return false; + if (events.length === 0) return; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); try { - const response = await fetch(BRIDGE_ENDPOINT, { + await fetch(BRIDGE_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", @@ -30,9 +30,7 @@ async function send(sessionId, events) { body: JSON.stringify(events), signal: controller.signal }); - return response.ok; } catch { - return false; } finally { clearTimeout(timeout); } @@ -71,17 +69,17 @@ function isPromptTelemetryEnabled(env = process.env) { } } async function trackBaseEvent(sessionId, key, value) { - if (!isBaseTelemetryEnabled()) return false; + if (!isBaseTelemetryEnabled()) return; const event = { id: randomUUID(), event_time: Date.now(), key, value: truncateValue(value) }; - return send(sessionId, [event]); + await send(sessionId, [event]); } async function trackBaseEvents(sessionId, entries) { - if (!isBaseTelemetryEnabled() || entries.length === 0) return false; + if (!isBaseTelemetryEnabled() || entries.length === 0) return; const now = Date.now(); const events = entries.map((entry) => ({ id: randomUUID(), @@ -89,7 +87,7 @@ async function trackBaseEvents(sessionId, entries) { key: entry.key, value: truncateValue(entry.value) })); - return send(sessionId, events); + await send(sessionId, events); } async function trackEvent(sessionId, key, value) { if (!isPromptTelemetryEnabled()) return; diff --git a/hooks/user-prompt-submit-telemetry.mjs b/hooks/user-prompt-submit-telemetry.mjs index 27c6a29..02c8e44 100755 --- a/hooks/user-prompt-submit-telemetry.mjs +++ b/hooks/user-prompt-submit-telemetry.mjs @@ -4,18 +4,9 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; import { homedir, tmpdir } from "os"; import { join, dirname } from "path"; -import { - buildSessionVercelProjectLinkState, - readSessionVercelProjectLinkState, - resolveHookProjectRoot, - resolveVercelProjectLink, - shouldRefreshSessionVercelProjectLink, - writeSessionVercelProjectLinkState -} from "./hook-env.mjs"; -import { getTelemetryOverride, isPromptTelemetryEnabled, trackBaseEvents, trackEvents } from "./telemetry.mjs"; +import { getTelemetryOverride, isPromptTelemetryEnabled, trackEvents } from "./telemetry.mjs"; var PREF_PATH = join(homedir(), ".claude", "vercel-plugin-telemetry-preference"); var MIN_PROMPT_LENGTH = 10; -var VERCEL_PROJECT_LINK_REFRESH_MS = 60 * 60 * 1e3; function parseStdin() { try { const raw = readFileSync(0, "utf-8").trim(); @@ -31,44 +22,11 @@ function resolveSessionId(input) { function resolvePrompt(input) { return input.prompt || input.message || ""; } -async function maybeTrackVercelProjectLink(sessionId, projectRoot) { - const now = Date.now(); - const previousState = readSessionVercelProjectLinkState(sessionId); - if (!shouldRefreshSessionVercelProjectLink(previousState, projectRoot, now, VERCEL_PROJECT_LINK_REFRESH_MS)) { - return; - } - const nextLink = resolveVercelProjectLink(projectRoot); - const telemetryEntries = []; - if (nextLink) { - if (previousState?.lastSentProjectId !== nextLink.projectId || previousState?.lastSentOrgId !== nextLink.orgId) { - telemetryEntries.push( - { key: "session:vercel_project_id", value: nextLink.projectId }, - { key: "session:vercel_org_id", value: nextLink.orgId } - ); - } - } else if (previousState?.lastSentProjectId !== void 0 || previousState?.lastSentOrgId !== void 0) { - telemetryEntries.push( - { key: "session:vercel_project_id", value: "" }, - { key: "session:vercel_org_id", value: "" } - ); - } - const trackedLink = telemetryEntries.length > 0 ? await trackBaseEvents(sessionId, telemetryEntries).catch(() => false) : false; - writeSessionVercelProjectLinkState(sessionId, buildSessionVercelProjectLinkState({ - previousState, - projectRoot, - resolvedAt: now, - nextLink, - trackedTelemetry: trackedLink - })); -} async function main() { const input = parseStdin(); const sessionId = input ? resolveSessionId(input) : ""; const prompt = input ? resolvePrompt(input) : ""; const telemetryOverride = getTelemetryOverride(); - if (telemetryOverride !== "off" && sessionId) { - await maybeTrackVercelProjectLink(sessionId, resolveHookProjectRoot(input)); - } if (isPromptTelemetryEnabled() && sessionId && prompt.length >= MIN_PROMPT_LENGTH) { await trackEvents(sessionId, [ { key: "prompt:text", value: prompt } diff --git a/tests/session-end-cleanup.test.ts b/tests/session-end-cleanup.test.ts index 48ae82a..e3e2c08 100644 --- a/tests/session-end-cleanup.test.ts +++ b/tests/session-end-cleanup.test.ts @@ -3,7 +3,6 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { dedupFilePath } from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); const HOOK_SCRIPT = join(ROOT, "hooks", "session-end-cleanup.mjs"); @@ -75,30 +74,6 @@ describe("session-end-cleanup", () => { rmSync(pendingLaunchDir, { recursive: true, force: true }); } }); - - test("removes linked project telemetry state files for the session", async () => { - const sessionId = "cleanup-project-link-state"; - const projectLinkFile = dedupFilePath(sessionId, "vercel-project-link"); - - writeFileSync( - projectLinkFile, - JSON.stringify({ projectId: "prj_cleanup", orgId: "team_cleanup", lastResolvedAt: Date.now() }), - "utf-8", - ); - - try { - expect(existsSync(projectLinkFile)).toBe(true); - - const { code, stdout, stderr } = await runSessionEnd({ session_id: sessionId }); - - expect(code).toBe(0); - expect(stdout).toBe(""); - expect(stderr).toBe(""); - expect(existsSync(projectLinkFile)).toBe(false); - } finally { - rmSync(projectLinkFile, { force: true }); - } - }); }); describe("hooks.json wiring", () => { diff --git a/tests/session-start-profiler.test.ts b/tests/session-start-profiler.test.ts index 6910f8c..6541a52 100644 --- a/tests/session-start-profiler.test.ts +++ b/tests/session-start-profiler.test.ts @@ -10,13 +10,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; -import { - readSessionFile, - readSessionVercelProjectLinkState, - resolveVercelProjectLink, - writeSessionVercelProjectLinkState, -} from "../hooks/src/hook-env.mts"; +import { readSessionFile } from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); const PROFILER = join(ROOT, "hooks", "session-start-profiler.mjs"); @@ -60,84 +54,6 @@ async function runProfiler(env: Record): Promise<{ return { code, stdout, stderr }; } -async function runProfilerWithCapture(args: { - env: Record; - payload?: Record; -}): Promise<{ - code: number; - stdout: string; - stderr: string; - requests: Array<{ url: string; body: string | null }>; -}> { - const captureFile = join(tempDir, `profiler-capture-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`); - const preloadFile = join(tempDir, `profiler-preload-${Date.now()}-${Math.random().toString(36).slice(2)}.mjs`); - writeFileSync( - preloadFile, - [ - 'import { appendFileSync } from "node:fs";', - 'const captureFile = process.env.VERCEL_PLUGIN_CAPTURE_FILE;', - 'globalThis.fetch = async (url, options = {}) => {', - ' if (captureFile) {', - ' appendFileSync(captureFile, JSON.stringify({', - ' url: String(url),', - ' body: typeof options.body === "string" ? options.body : null,', - ' }) + "\\n", "utf-8");', - ' }', - ' return new Response(null, { status: 204 });', - '};', - ].join("\n"), - "utf-8", - ); - - const mergedEnv: Record = { - ...(process.env as Record), - VERCEL_PLUGIN_CAPTURE_FILE: captureFile, - NODE_OPTIONS: [ - process.env.NODE_OPTIONS, - `--import=${pathToFileURL(preloadFile).href}`, - ].filter(Boolean).join(" "), - }; - - for (const [key, value] of Object.entries(args.env)) { - if (value === undefined) { - delete mergedEnv[key]; - continue; - } - mergedEnv[key] = value; - } - - const proc = Bun.spawn([NODE_BIN, PROFILER], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: mergedEnv, - }); - - proc.stdin.write(JSON.stringify(args.payload ?? { session_id: testSessionId })); - proc.stdin.end(); - - const code = await proc.exited; - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const requests = existsSync(captureFile) - ? readFileSync(captureFile, "utf-8") - .trim() - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as { url: string; body: string | null }) - : []; - - rmSync(captureFile, { force: true }); - rmSync(preloadFile, { force: true }); - - return { code, stdout, stderr, requests }; -} - -function parseTrackedEntries(body: string | null): Array<{ key: string; value: string }> { - return (JSON.parse(body ?? "[]") as Array<{ key: string; value: string }>) - .map((entry) => ({ key: entry.key, value: entry.value })); -} - function parseLikelySkills(_envFileContent?: string): string[] { return readSessionFile(testSessionId, "likely-skills").split(",").filter(Boolean); } @@ -153,10 +69,6 @@ function readGreenfieldState(): string { return readSessionFile(testSessionId, "greenfield"); } -function readVercelProjectLinkState() { - return readSessionVercelProjectLinkState(testSessionId); -} - function makeMockCommand(binDir: string, commandName: string, body: string): void { const commandPath = join(binDir, commandName); writeFileSync(commandPath, `#!/bin/sh\n${body}\n`, "utf-8"); @@ -658,159 +570,6 @@ describe("session-start-profiler", () => { expect(readGreenfieldState()).toBe("true"); }); - test("persists linked Vercel project IDs in session state when project.json is present", async () => { - const projectDir = join(tempDir, "linked-project"); - mkdirSync(join(projectDir, ".vercel"), { recursive: true }); - writeFileSync( - join(projectDir, ".vercel", "project.json"), - JSON.stringify({ projectId: "prj_linked", orgId: "team_linked" }), - ); - - const result = await runProfiler({ - CLAUDE_ENV_FILE: envFile, - CLAUDE_PROJECT_ROOT: projectDir, - }); - - expect(result.code).toBe(0); - expect(readVercelProjectLinkState()).toMatchObject({ - lastResolvedRoot: projectDir, - projectId: "prj_linked", - orgId: "team_linked", - }); - expect(readVercelProjectLinkState()?.lastResolvedAt).toEqual(expect.any(Number)); - }); - - test("skips linked project resolution and state writes when base telemetry is disabled", async () => { - const projectDir = join(tempDir, "telemetry-off-linked-project"); - mkdirSync(join(projectDir, ".vercel"), { recursive: true }); - writeFileSync( - join(projectDir, ".vercel", "project.json"), - JSON.stringify({ projectId: "prj_linked", orgId: "team_linked" }), - ); - - const result = await runProfilerWithCapture({ - env: { - CLAUDE_ENV_FILE: envFile, - CLAUDE_PROJECT_ROOT: projectDir, - VERCEL_PLUGIN_TELEMETRY: "off", - }, - }); - - expect(result.code).toBe(0); - expect(result.requests).toHaveLength(0); - expect(readVercelProjectLinkState()).toBeNull(); - }); - - test("prefers payload cwd over stale env roots when resolving linked Vercel project telemetry", async () => { - const staleRoot = join(tempDir, "stale-linked-root"); - const currentRoot = join(tempDir, "current-linked-root"); - mkdirSync(join(staleRoot, ".vercel"), { recursive: true }); - mkdirSync(join(currentRoot, ".vercel"), { recursive: true }); - writeFileSync( - join(staleRoot, ".vercel", "project.json"), - JSON.stringify({ projectId: "prj_stale", orgId: "team_stale" }), - ); - writeFileSync( - join(currentRoot, ".vercel", "project.json"), - JSON.stringify({ projectId: "prj_current", orgId: "team_current" }), - ); - - const result = await runProfilerWithCapture({ - env: { - CLAUDE_ENV_FILE: envFile, - CLAUDE_PROJECT_ROOT: staleRoot, - }, - payload: { - session_id: testSessionId, - cwd: currentRoot, - }, - }); - - expect(result.code).toBe(0); - expect(result.requests).toHaveLength(1); - const trackedEntries = parseTrackedEntries(result.requests[0].body); - expect(trackedEntries).toContainEqual({ - key: "session:vercel_project_id", - value: "prj_current", - }); - expect(trackedEntries).toContainEqual({ - key: "session:vercel_org_id", - value: "team_current", - }); - expect(trackedEntries).not.toContainEqual({ - key: "session:vercel_project_id", - value: "prj_stale", - }); - expect(readVercelProjectLinkState()).toMatchObject({ - lastResolvedRoot: currentRoot, - projectId: "prj_current", - orgId: "team_current", - lastSentProjectId: "prj_current", - lastSentOrgId: "team_current", - }); - }); - - test("replaces stale linked Vercel project state when current project is unlinked", async () => { - const projectDir = join(tempDir, "unlinked-project"); - mkdirSync(projectDir); - writeSessionVercelProjectLinkState(testSessionId, { - lastResolvedAt: Date.now(), - lastResolvedRoot: join(tempDir, "old-linked-root"), - projectId: "prj_stale", - orgId: "team_stale", - lastSentProjectId: "prj_stale", - lastSentOrgId: "team_stale", - }); - - const result = await runProfiler({ - CLAUDE_ENV_FILE: envFile, - CLAUDE_PROJECT_ROOT: projectDir, - }); - - expect(result.code).toBe(0); - expect(readVercelProjectLinkState()).toMatchObject({ - lastResolvedRoot: projectDir, - }); - expect(readVercelProjectLinkState()?.projectId).toBeUndefined(); - expect(readVercelProjectLinkState()?.orgId).toBeUndefined(); - expect(readVercelProjectLinkState()?.lastSentProjectId).toBeUndefined(); - expect(readVercelProjectLinkState()?.lastSentOrgId).toBeUndefined(); - }); - - test("emits tombstone telemetry when a previously linked session starts unlinked", async () => { - const projectDir = join(tempDir, "session-start-unlinked"); - mkdirSync(projectDir); - writeSessionVercelProjectLinkState(testSessionId, { - lastResolvedAt: Date.now(), - lastResolvedRoot: join(tempDir, "old-linked-root"), - projectId: "prj_old", - orgId: "team_old", - lastSentProjectId: "prj_old", - lastSentOrgId: "team_old", - }); - - const result = await runProfilerWithCapture({ - env: { - CLAUDE_ENV_FILE: envFile, - CLAUDE_PROJECT_ROOT: projectDir, - }, - }); - - expect(result.code).toBe(0); - expect(result.requests).toHaveLength(1); - expect(parseTrackedEntries(result.requests[0].body)).toContainEqual({ - key: "session:vercel_project_id", - value: "", - }); - expect(parseTrackedEntries(result.requests[0].body)).toContainEqual({ - key: "session:vercel_org_id", - value: "", - }); - expect(readVercelProjectLinkState()?.lastResolvedRoot).toBe(projectDir); - expect(readVercelProjectLinkState()?.lastSentProjectId).toBeUndefined(); - expect(readVercelProjectLinkState()?.lastSentOrgId).toBeUndefined(); - }); - test("hooks.json registers profiler after seen-skills init", () => { const hooksJson = JSON.parse( readFileSync(join(ROOT, "hooks", "hooks.json"), "utf-8"), @@ -1025,109 +784,75 @@ describe("profileProject (unit)", () => { }); }); -describe("resolveVercelProjectLink (unit)", () => { - test("reads .vercel/project.json from the nearest parent", () => { - const projectDir = join(tempDir, "unit-linked-project"); - const nestedDir = join(projectDir, "src", "app"); +describe("session-start telemetry helpers (unit)", () => { + test("reads linked Vercel project IDs from .vercel/project.json", async () => { + const { readLinkedVercelProject } = await import("../hooks/session-start-profiler.mjs"); + const projectDir = join(tempDir, "unit-vercel-link"); mkdirSync(join(projectDir, ".vercel"), { recursive: true }); - mkdirSync(nestedDir, { recursive: true }); writeFileSync( join(projectDir, ".vercel", "project.json"), - JSON.stringify({ projectId: "prj_parent", orgId: "team_parent" }), + JSON.stringify({ projectId: "prj_123", orgId: "team_456" }), + "utf-8", ); - expect(resolveVercelProjectLink(nestedDir)).toEqual({ - projectId: "prj_parent", - orgId: "team_parent", - source: "project.json", + expect(readLinkedVercelProject(projectDir)).toEqual({ + projectId: "prj_123", + orgId: "team_456", }); }); - test("reads matching subproject from repo.json", () => { - const repoRoot = join(tempDir, "unit-repo-linked"); - const webDir = join(repoRoot, "apps", "web"); - mkdirSync(join(repoRoot, ".vercel"), { recursive: true }); - mkdirSync(webDir, { recursive: true }); + test("ignores incomplete linked Vercel project metadata", async () => { + const { readLinkedVercelProject } = await import("../hooks/session-start-profiler.mjs"); + const projectDir = join(tempDir, "unit-vercel-link-incomplete"); + mkdirSync(join(projectDir, ".vercel"), { recursive: true }); writeFileSync( - join(repoRoot, ".vercel", "repo.json"), - JSON.stringify({ - orgId: "team_repo", - projects: [ - { id: "prj_root", directory: "." }, - { id: "prj_web", directory: "apps/web" }, - ], - }), + join(projectDir, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_123" }), + "utf-8", ); - expect(resolveVercelProjectLink(webDir)).toEqual({ - projectId: "prj_web", - orgId: "team_repo", - source: "repo.json", - }); + expect(readLinkedVercelProject(projectDir)).toBeNull(); }); - test("falls back from settings-only project.json to repo.json", () => { - const repoRoot = join(tempDir, "unit-repo-settings-only"); - const webDir = join(repoRoot, "apps", "web"); - mkdirSync(join(repoRoot, ".vercel"), { recursive: true }); - mkdirSync(join(webDir, ".vercel"), { recursive: true }); - writeFileSync( - join(repoRoot, ".vercel", "repo.json"), - JSON.stringify({ - orgId: "team_repo", - projects: [{ id: "prj_web", directory: "apps/web" }], - }), - ); - writeFileSync(join(webDir, ".vercel", "project.json"), JSON.stringify({ settings: {} })); + test("includes linked Vercel project IDs in session telemetry only when present", async () => { + const { buildSessionStartTelemetryEntries } = await import("../hooks/session-start-profiler.mjs"); - expect(resolveVercelProjectLink(webDir)).toEqual({ - projectId: "prj_web", - orgId: "team_repo", - source: "repo.json", + const withLink = buildSessionStartTelemetryEntries({ + deviceId: "device_123", + likelySkills: ["nextjs", "vercel-cli"], + greenfield: false, + cliStatus: { + installed: true, + currentVersion: "44.7.3", + needsUpdate: false, + }, + vercelProjectLink: { + projectId: "prj_123", + orgId: "team_456", + }, }); - }); - - test("falls back to an ancestor link when a nested repo.json has no matching project", () => { - const repoRoot = join(tempDir, "unit-repo-nested-fallback"); - const webDir = join(repoRoot, "apps", "web"); - const nestedDir = join(webDir, "src"); - mkdirSync(join(repoRoot, ".vercel"), { recursive: true }); - mkdirSync(join(webDir, ".vercel"), { recursive: true }); - mkdirSync(nestedDir, { recursive: true }); - writeFileSync( - join(repoRoot, ".vercel", "project.json"), - JSON.stringify({ projectId: "prj_ancestor", orgId: "team_ancestor" }), - ); - writeFileSync( - join(webDir, ".vercel", "repo.json"), - JSON.stringify({ - orgId: "team_nested", - projects: [{ id: "prj_other", directory: "apps/other" }], - }), - ); - - expect(resolveVercelProjectLink(nestedDir)).toEqual({ - projectId: "prj_ancestor", - orgId: "team_ancestor", - source: "project.json", + expect(withLink).toContainEqual({ + key: "session:vercel_project_id", + value: "prj_123", + }); + expect(withLink).toContainEqual({ + key: "session:vercel_org_id", + value: "team_456", }); - }); - - test("returns null for an ambiguous repo root with multiple linked subprojects", () => { - const repoRoot = join(tempDir, "unit-repo-ambiguous"); - mkdirSync(join(repoRoot, ".vercel"), { recursive: true }); - writeFileSync( - join(repoRoot, ".vercel", "repo.json"), - JSON.stringify({ - orgId: "team_repo", - projects: [ - { id: "prj_web", directory: "apps/web" }, - { id: "prj_api", directory: "apps/api" }, - ], - }), - ); - expect(resolveVercelProjectLink(repoRoot)).toBeNull(); + const withoutLink = buildSessionStartTelemetryEntries({ + deviceId: "device_123", + likelySkills: ["nextjs", "vercel-cli"], + greenfield: false, + cliStatus: { + installed: true, + currentVersion: "44.7.3", + needsUpdate: false, + }, + vercelProjectLink: null, + }); + expect(withoutLink.find((entry) => entry.key === "session:vercel_project_id")).toBeUndefined(); + expect(withoutLink.find((entry) => entry.key === "session:vercel_org_id")).toBeUndefined(); }); }); diff --git a/tests/session-start-seen-skills.test.ts b/tests/session-start-seen-skills.test.ts index 4961e2a..7ede6ea 100644 --- a/tests/session-start-seen-skills.test.ts +++ b/tests/session-start-seen-skills.test.ts @@ -6,6 +6,7 @@ import { join, resolve, sep } from "node:path"; import { dedupClaimDirPath, dedupFilePath, + removeAllSessionDedupArtifacts, removeSessionClaimDir, tryClaimSessionKey, } from "../hooks/src/hook-env.mts"; @@ -113,7 +114,7 @@ describe("session-start-seen-skills hook", () => { }); }); - test("test_clear_event_wipes_dedup_state_but_preserves_project_link_state", async () => { + test("test_clear_event_wipes_claim_dir_and_session_file", async () => { const sessionId = `test-clear-${Date.now()}`; try { @@ -123,17 +124,11 @@ describe("session-start-seen-skills hook", () => { expect(tryClaimSessionKey(sessionId, "seen-context-chunks", "nextjs-platform")).toBe(true); writeFileSync(dedupFilePath(sessionId, "seen-skills"), "nextjs,ai-sdk", "utf-8"); writeFileSync(dedupFilePath(sessionId, "seen-context-chunks"), "nextjs-platform", "utf-8"); - writeFileSync( - dedupFilePath(sessionId, "vercel-project-link"), - JSON.stringify({ projectId: "prj_clear", orgId: "team_clear", lastResolvedAt: Date.now() }), - "utf-8", - ); expect(existsSync(dedupClaimDirPath(sessionId, "seen-skills"))).toBe(true); expect(existsSync(dedupFilePath(sessionId, "seen-skills"))).toBe(true); expect(existsSync(dedupClaimDirPath(sessionId, "seen-context-chunks"))).toBe(true); expect(existsSync(dedupFilePath(sessionId, "seen-context-chunks"))).toBe(true); - expect(existsSync(dedupFilePath(sessionId, "vercel-project-link"))).toBe(true); // Fire the hook with a "clear" event const result = await runSessionStart( @@ -144,32 +139,25 @@ describe("session-start-seen-skills hook", () => { expect(result.code).toBe(0); expect(result.stdout).toBe(""); - // Dedup claim dirs/files should be gone, but project link state should remain + // Both claim dir and session file should be gone expect(existsSync(dedupClaimDirPath(sessionId, "seen-skills"))).toBe(false); expect(existsSync(dedupFilePath(sessionId, "seen-skills"))).toBe(false); expect(existsSync(dedupClaimDirPath(sessionId, "seen-context-chunks"))).toBe(false); expect(existsSync(dedupFilePath(sessionId, "seen-context-chunks"))).toBe(false); - expect(existsSync(dedupFilePath(sessionId, "vercel-project-link"))).toBe(true); } finally { rmSync(dedupClaimDirPath(sessionId, "seen-skills"), { recursive: true, force: true }); rmSync(dedupClaimDirPath(sessionId, "seen-context-chunks"), { recursive: true, force: true }); try { rmSync(dedupFilePath(sessionId, "seen-skills")); } catch {} try { rmSync(dedupFilePath(sessionId, "seen-context-chunks")); } catch {} - try { rmSync(dedupFilePath(sessionId, "vercel-project-link")); } catch {} } }); - test("test_compact_event_wipes_dedup_state_but_preserves_project_link_state", async () => { + test("test_compact_event_wipes_claim_dir_and_session_file", async () => { const sessionId = `test-compact-${Date.now()}`; try { expect(tryClaimSessionKey(sessionId, "seen-skills", "swr")).toBe(true); writeFileSync(dedupFilePath(sessionId, "seen-skills"), "swr", "utf-8"); - writeFileSync( - dedupFilePath(sessionId, "vercel-project-link"), - JSON.stringify({ projectId: "prj_compact", orgId: "team_compact", lastResolvedAt: Date.now() }), - "utf-8", - ); const result = await runSessionStart( { CLAUDE_ENV_FILE: undefined }, @@ -179,11 +167,9 @@ describe("session-start-seen-skills hook", () => { expect(result.code).toBe(0); expect(existsSync(dedupClaimDirPath(sessionId, "seen-skills"))).toBe(false); expect(existsSync(dedupFilePath(sessionId, "seen-skills"))).toBe(false); - expect(existsSync(dedupFilePath(sessionId, "vercel-project-link"))).toBe(true); } finally { rmSync(dedupClaimDirPath(sessionId, "seen-skills"), { recursive: true, force: true }); try { rmSync(dedupFilePath(sessionId, "seen-skills")); } catch {} - try { rmSync(dedupFilePath(sessionId, "vercel-project-link")); } catch {} } }); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index c5114b2..6bed4ec 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -1,16 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; -import { - buildSessionVercelProjectLinkState, - parseSessionVercelProjectLinkState, - readSessionVercelProjectLinkState, - resolveHookProjectRoot, - shouldRefreshSessionVercelProjectLink, - writeSessionVercelProjectLinkState, -} from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); const TELEMETRY_MODULE = join(ROOT, "hooks", "telemetry.mjs"); @@ -109,94 +100,6 @@ async function runPromptHook(env: Record): Promise<{ return { code, stdout, stderr }; } -async function runPromptHookWithCapture(args: { - env?: Record; - payload?: Record; -}): Promise<{ - code: number; - stdout: string; - stderr: string; - requests: Array<{ url: string; body: string | null; headers: Record | null }>; -}> { - const captureFile = join(tempHome, `prompt-hook-capture-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`); - const preloadFile = join(tempHome, `prompt-hook-preload-${Date.now()}-${Math.random().toString(36).slice(2)}.mjs`); - writeFileSync( - preloadFile, - [ - 'import { appendFileSync } from "node:fs";', - 'const captureFile = process.env.VERCEL_PLUGIN_CAPTURE_FILE;', - 'globalThis.fetch = async (url, options = {}) => {', - ' if (captureFile) {', - ' appendFileSync(captureFile, JSON.stringify({', - ' url: String(url),', - ' body: typeof options.body === "string" ? options.body : null,', - ' headers: options.headers && typeof options.headers === "object" ? options.headers : null,', - ' }) + "\\n", "utf-8");', - ' }', - ' return new Response(null, { status: 204 });', - '};', - ].join("\n"), - "utf-8", - ); - - const mergedEnv: Record = { - ...(process.env as Record), - VERCEL_PLUGIN_CAPTURE_FILE: captureFile, - NODE_OPTIONS: [ - process.env.NODE_OPTIONS, - `--import=${pathToFileURL(preloadFile).href}`, - ].filter(Boolean).join(" "), - }; - - for (const [key, value] of Object.entries(args.env ?? {})) { - if (value === undefined) { - delete mergedEnv[key]; - continue; - } - mergedEnv[key] = value; - } - - const proc = Bun.spawn([NODE_BIN, USER_PROMPT_HOOK], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: mergedEnv, - }); - - proc.stdin.write(JSON.stringify(args.payload ?? { - 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(); - const requests = existsSync(captureFile) - ? readFileSync(captureFile, "utf-8") - .trim() - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as { url: string; body: string | null; headers: Record | null }) - : []; - - rmSync(captureFile, { force: true }); - rmSync(preloadFile, { force: true }); - - return { code, stdout, stderr, requests }; -} - -function writePromptTelemetryPreference(value: "enabled" | "disabled"): void { - const claudeDir = join(tempHome, ".claude"); - mkdirSync(claudeDir, { recursive: true }); - writeFileSync(join(claudeDir, "vercel-plugin-telemetry-preference"), value, "utf-8"); -} - -function parseTrackedEntries(body: string | null): Array<{ key: string; value: string }> { - return (JSON.parse(body ?? "[]") as Array<{ key: string; value: string }>) - .map((entry) => ({ key: entry.key, value: entry.value })); -} - beforeEach(() => { tempHome = mkdtempSync(join(tmpdir(), "telemetry-home-")); }); @@ -232,291 +135,3 @@ describe("telemetry controls", () => { expect(existsSync(prefPath)).toBe(false); }); }); - -describe("Vercel project link refresh", () => { - test("prefers per-prompt roots over stale session env roots", () => { - const promptRoot = join(tempHome, "apps", "web"); - const workspaceRoot = join(tempHome, "apps", "api"); - const envRoot = join(tempHome, "stale-session-root"); - - expect(resolveHookProjectRoot({ cwd: promptRoot }, { CLAUDE_PROJECT_ROOT: envRoot })).toBe(promptRoot); - expect( - resolveHookProjectRoot( - { workspace_roots: [workspaceRoot] }, - { CLAUDE_PROJECT_ROOT: envRoot }, - ), - ).toBe(workspaceRoot); - }); - - test("parses last sent project metadata from session state", () => { - expect( - parseSessionVercelProjectLinkState(JSON.stringify({ - lastResolvedAt: 123, - lastResolvedRoot: "/repo/apps/web", - projectId: "prj_current", - orgId: "team_current", - lastSentProjectId: "prj_sent", - lastSentOrgId: "team_sent", - })), - ).toEqual({ - lastResolvedAt: 123, - lastResolvedRoot: "/repo/apps/web", - projectId: "prj_current", - orgId: "team_current", - lastSentProjectId: "prj_sent", - lastSentOrgId: "team_sent", - }); - }); - - test("builds next project link state without two-phase lastSent mutations", () => { - const previousState = { - lastResolvedAt: 100, - lastResolvedRoot: "/repo/apps/old", - projectId: "prj_old", - orgId: "team_old", - lastSentProjectId: "prj_old", - lastSentOrgId: "team_old", - }; - - expect(buildSessionVercelProjectLinkState({ - previousState, - projectRoot: "/repo/apps/web", - resolvedAt: 200, - nextLink: { projectId: "prj_current", orgId: "team_current", source: "project.json" }, - trackedTelemetry: false, - })).toEqual({ - lastResolvedAt: 200, - lastResolvedRoot: "/repo/apps/web", - projectId: "prj_current", - orgId: "team_current", - lastSentProjectId: "prj_old", - lastSentOrgId: "team_old", - }); - - expect(buildSessionVercelProjectLinkState({ - previousState, - projectRoot: "/repo/apps/web", - resolvedAt: 201, - nextLink: { projectId: "prj_current", orgId: "team_current", source: "repo.json" }, - trackedTelemetry: true, - })).toEqual({ - lastResolvedAt: 201, - lastResolvedRoot: "/repo/apps/web", - projectId: "prj_current", - orgId: "team_current", - lastSentProjectId: "prj_current", - lastSentOrgId: "team_current", - }); - - expect(buildSessionVercelProjectLinkState({ - previousState, - projectRoot: "/repo/apps/plain", - resolvedAt: 202, - nextLink: null, - trackedTelemetry: true, - })).toEqual({ - lastResolvedAt: 202, - lastResolvedRoot: "/repo/apps/plain", - }); - }); - - test("refreshes when the cached link is missing, root changed, unsent, or at least an hour old", () => { - const now = Date.now(); - const currentRoot = "/repo/apps/web"; - - expect(shouldRefreshSessionVercelProjectLink(null, currentRoot, now, 3_600_000)).toBe(true); - expect( - shouldRefreshSessionVercelProjectLink( - { lastResolvedAt: now - 1, projectId: "prj_unsent", orgId: "team_unsent", lastResolvedRoot: currentRoot }, - currentRoot, - now, - 3_600_000, - ), - ).toBe(true); - expect( - shouldRefreshSessionVercelProjectLink( - { - lastResolvedAt: now - 1, - lastResolvedRoot: "/repo/apps/api", - projectId: "prj_sent", - orgId: "team_sent", - lastSentProjectId: "prj_sent", - lastSentOrgId: "team_sent", - }, - currentRoot, - now, - 3_600_000, - ), - ).toBe(true); - expect( - shouldRefreshSessionVercelProjectLink( - { - lastResolvedAt: now - 3_599_999, - lastResolvedRoot: currentRoot, - projectId: "prj_sent", - orgId: "team_sent", - lastSentProjectId: "prj_sent", - lastSentOrgId: "team_sent", - }, - currentRoot, - now, - 3_600_000, - ), - ).toBe(false); - expect( - shouldRefreshSessionVercelProjectLink( - { - lastResolvedAt: now - 3_600_000, - lastResolvedRoot: currentRoot, - projectId: "prj_sent", - orgId: "team_sent", - lastSentProjectId: "prj_sent", - lastSentOrgId: "team_sent", - }, - currentRoot, - now, - 3_600_000, - ), - ).toBe(true); - }); - - test("prompt hook re-resolves immediately when cwd changes to a different linked project", async () => { - const sessionId = `telemetry-project-link-change-${Date.now()}`; - const staleRoot = join(tempHome, "stale-root"); - const currentRoot = join(tempHome, "apps", "web"); - mkdirSync(join(staleRoot, ".vercel"), { recursive: true }); - mkdirSync(join(currentRoot, ".vercel"), { recursive: true }); - writeFileSync( - join(staleRoot, ".vercel", "project.json"), - JSON.stringify({ projectId: "prj_stale", orgId: "team_stale" }), - "utf-8", - ); - writeFileSync( - join(currentRoot, ".vercel", "project.json"), - JSON.stringify({ projectId: "prj_current", orgId: "team_current" }), - "utf-8", - ); - writeSessionVercelProjectLinkState(sessionId, { - lastResolvedAt: Date.now(), - lastResolvedRoot: staleRoot, - projectId: "prj_stale", - orgId: "team_stale", - lastSentProjectId: "prj_stale", - lastSentOrgId: "team_stale", - }); - writePromptTelemetryPreference("disabled"); - - const result = await runPromptHookWithCapture({ - env: { - HOME: tempHome, - CLAUDE_PROJECT_ROOT: staleRoot, - }, - payload: { - session_id: sessionId, - prompt: "refresh project telemetry", - cwd: currentRoot, - }, - }); - - expect(result.code).toBe(0); - expect(result.stdout).toBe("{}"); - expect(result.requests).toHaveLength(1); - expect(parseTrackedEntries(result.requests[0].body)).toEqual([ - { key: "session:vercel_project_id", value: "prj_current" }, - { key: "session:vercel_org_id", value: "team_current" }, - ]); - expect(readSessionVercelProjectLinkState(sessionId)).toMatchObject({ - lastResolvedRoot: currentRoot, - projectId: "prj_current", - orgId: "team_current", - lastSentProjectId: "prj_current", - lastSentOrgId: "team_current", - }); - }); - - test("prompt hook clears cached project ids when the current project is no longer linked", async () => { - const sessionId = `telemetry-project-link-removed-${Date.now()}`; - const staleRoot = join(tempHome, "old-linked-root"); - const unlinkedRoot = join(tempHome, "plain-project"); - mkdirSync(join(staleRoot, ".vercel"), { recursive: true }); - mkdirSync(unlinkedRoot, { recursive: true }); - writeFileSync( - join(staleRoot, ".vercel", "project.json"), - JSON.stringify({ projectId: "prj_old", orgId: "team_old" }), - "utf-8", - ); - writeSessionVercelProjectLinkState(sessionId, { - lastResolvedAt: Date.now(), - lastResolvedRoot: staleRoot, - projectId: "prj_old", - orgId: "team_old", - lastSentProjectId: "prj_old", - lastSentOrgId: "team_old", - }); - writePromptTelemetryPreference("disabled"); - - const result = await runPromptHookWithCapture({ - env: { - HOME: tempHome, - CLAUDE_PROJECT_ROOT: staleRoot, - }, - payload: { - session_id: sessionId, - prompt: "refresh project telemetry", - cwd: unlinkedRoot, - }, - }); - - expect(result.code).toBe(0); - expect(result.stdout).toBe("{}"); - expect(result.requests).toHaveLength(1); - expect(parseTrackedEntries(result.requests[0].body)).toEqual([ - { key: "session:vercel_project_id", value: "" }, - { key: "session:vercel_org_id", value: "" }, - ]); - const state = readSessionVercelProjectLinkState(sessionId); - expect(state?.lastResolvedRoot).toBe(unlinkedRoot); - expect(state?.projectId).toBeUndefined(); - expect(state?.orgId).toBeUndefined(); - expect(state?.lastSentProjectId).toBeUndefined(); - expect(state?.lastSentOrgId).toBeUndefined(); - expect(state?.lastResolvedAt).toEqual(expect.any(Number)); - }); - - test("prompt hook does not re-emit linked project ids within the refresh window", async () => { - const sessionId = `telemetry-project-link-unchanged-${Date.now()}`; - const linkedRoot = join(tempHome, "steady-linked-root"); - mkdirSync(join(linkedRoot, ".vercel"), { recursive: true }); - writeFileSync( - join(linkedRoot, ".vercel", "project.json"), - JSON.stringify({ projectId: "prj_same", orgId: "team_same" }), - "utf-8", - ); - const initialState = { - lastResolvedAt: Date.now(), - lastResolvedRoot: linkedRoot, - projectId: "prj_same", - orgId: "team_same", - lastSentProjectId: "prj_same", - lastSentOrgId: "team_same", - }; - writeSessionVercelProjectLinkState(sessionId, initialState); - writePromptTelemetryPreference("disabled"); - - const result = await runPromptHookWithCapture({ - env: { - HOME: tempHome, - }, - payload: { - session_id: sessionId, - prompt: "refresh project telemetry", - cwd: linkedRoot, - }, - }); - - expect(result.code).toBe(0); - expect(result.stdout).toBe("{}"); - expect(result.requests).toHaveLength(0); - expect(readSessionVercelProjectLinkState(sessionId)).toEqual(initialState); - }); -}); From 1e3ae195763fdfa11d42058f42fbff698b223f20 Mon Sep 17 00:00:00 2001 From: Grappeggia Date: Wed, 8 Apr 2026 08:29:43 -0700 Subject: [PATCH 8/9] verify linked project telemetry across platforms Resolve session-start roots from hook payloads and add focused cross-platform coverage so linked Vercel project IDs are attributed correctly on macOS, Linux, and Windows. Bump the published plugin manifests for the follow-up change. --- .cursor-plugin/plugin.json | 2 +- .github/workflows/ci.yml | 26 +++ .plugin/plugin.json | 2 +- hooks/session-hooks-platform-compat.test.ts | 8 +- hooks/session-start-profiler.mjs | 44 +++-- hooks/src/session-start-profiler.mts | 62 +++++-- package.json | 2 +- tests/session-start-profiler.test.ts | 171 ++++++++++++++++++-- 8 files changed, 275 insertions(+), 42 deletions(-) diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index d07f593..92157f2 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vercel", - "version": "0.32.0", + "version": "0.32.1", "description": "Build and deploy web apps and agents", "author": { "name": "Vercel Labs", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8305653..913d84b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,29 @@ jobs: - name: Test run: bun test + + session-start-profiler-cross-platform: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build hooks + run: bun run build:hooks + + - name: Test linked project telemetry coverage + run: bun test tests/session-start-profiler.test.ts -t "session-start telemetry helpers|uses the hook payload root for linked project telemetry" + + - name: Test session hook platform compatibility + run: bun test hooks/session-hooks-platform-compat.test.ts diff --git a/.plugin/plugin.json b/.plugin/plugin.json index 1eb568e..a1cbe91 100644 --- a/.plugin/plugin.json +++ b/.plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vercel-plugin", - "version": "0.32.0", + "version": "0.32.1", "description": "Comprehensive Vercel ecosystem plugin — relational knowledge graph, skills for every major product, specialized agents, and Vercel conventions. Turns any AI agent into a Vercel expert.", "author": { "name": "Vercel Labs", diff --git a/hooks/session-hooks-platform-compat.test.ts b/hooks/session-hooks-platform-compat.test.ts index 318ea94..5a98ce0 100644 --- a/hooks/session-hooks-platform-compat.test.ts +++ b/hooks/session-hooks-platform-compat.test.ts @@ -52,7 +52,6 @@ describe("session hook platform compatibility", () => { {}, ); const envVars = buildSessionStartProfilerEnvVars({ - agentBrowserAvailable: true, greenfield: true, likelySkills: ["ai-sdk", "nextjs"], setupSignals: { @@ -63,12 +62,15 @@ describe("session hook platform compatibility", () => { }); expect(platform).toBe("cursor"); - expect(resolveSessionStartProjectRoot({ CURSOR_PROJECT_DIR: "/tmp/cursor-root" })).toBe( + expect(resolveSessionStartProjectRoot( + { cwd: "/tmp/payload-root", workspace_roots: ["/tmp/workspace-root"] }, + { CURSOR_PROJECT_DIR: "/tmp/cursor-root" }, + )).toBe("/tmp/payload-root"); + expect(resolveSessionStartProjectRoot(null, { CURSOR_PROJECT_DIR: "/tmp/cursor-root" })).toBe( "/tmp/cursor-root", ); expect(JSON.parse(formatSessionStartProfilerCursorOutput(envVars, ["profile ready"]))).toEqual({ env: { - VERCEL_PLUGIN_AGENT_BROWSER_AVAILABLE: "1", VERCEL_PLUGIN_GREENFIELD: "true", VERCEL_PLUGIN_LIKELY_SKILLS: "ai-sdk,nextjs", VERCEL_PLUGIN_BOOTSTRAP_HINTS: "greenfield", diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index bb005ea..1db704d 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -18,7 +18,7 @@ import { import { pluginRoot, profileCachePath, safeReadJson, writeSessionFile } from "./hook-env.mjs"; import { createLogger, logCaughtError } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; -import { trackBaseEvents, getOrCreateDeviceId } from "./telemetry.mjs"; +import { getOrCreateDeviceId, isBaseTelemetryEnabled, trackBaseEvents } from "./telemetry.mjs"; var FILE_MARKERS = [ { file: "next.config.js", skills: ["nextjs", "turbopack"] }, { file: "next.config.mjs", skills: ["nextjs", "turbopack"] }, @@ -330,6 +330,23 @@ function buildSessionStartTelemetryEntries(args) { } return entries; } +async function trackSessionStartTelemetry(args) { + if (!(args.telemetryEnabled ?? isBaseTelemetryEnabled())) { + return; + } + const deviceId = (args.getDeviceId ?? getOrCreateDeviceId)(); + const vercelProjectLink = (args.readProjectLink ?? readLinkedVercelProject)(args.projectRoot); + await (args.trackEvents ?? trackBaseEvents)( + args.sessionId, + buildSessionStartTelemetryEntries({ + deviceId, + likelySkills: args.likelySkills, + greenfield: args.greenfield, + cliStatus: args.cliStatus, + vercelProjectLink + }) + ); +} function parseSessionStartInput(raw) { try { if (!raw.trim()) return null; @@ -352,8 +369,12 @@ function normalizeSessionStartSessionId(input) { const sessionId = normalizeInput(input).sessionId; return sessionId || null; } -function resolveSessionStartProjectRoot(env = process.env) { - return env.CLAUDE_PROJECT_ROOT ?? env.CURSOR_PROJECT_DIR ?? process.cwd(); +function nonEmptySessionStartPath(value) { + return typeof value === "string" && value.trim() !== "" ? value : null; +} +function resolveSessionStartProjectRoot(input, env = process.env) { + const workspaceRoot = Array.isArray(input?.workspace_roots) ? input.workspace_roots.find((entry) => typeof entry === "string" && entry.trim() !== "") : null; + return nonEmptySessionStartPath(input?.cwd) ?? workspaceRoot ?? nonEmptySessionStartPath(env.CLAUDE_PROJECT_ROOT) ?? nonEmptySessionStartPath(env.CURSOR_PROJECT_DIR) ?? process.cwd(); } function collectBrokenSkillFrontmatterNames(files) { return [...new Set( @@ -441,7 +462,7 @@ async function main() { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); - const projectRoot = resolveSessionStartProjectRoot(); + const projectRoot = resolveSessionStartProjectRoot(hookInput); logBrokenSkillFrontmatterSummary(); const greenfield = checkGreenfield(projectRoot); const cliStatus = checkVercelCli(); @@ -502,15 +523,13 @@ async function main() { } } if (sessionId) { - const deviceId = getOrCreateDeviceId(); - const vercelProjectLink = readLinkedVercelProject(projectRoot); - await trackBaseEvents(sessionId, buildSessionStartTelemetryEntries({ - deviceId, + await trackSessionStartTelemetry({ + sessionId, + projectRoot, likelySkills, greenfield: greenfield !== null, - cliStatus, - vercelProjectLink - })).catch(() => { + cliStatus + }).catch(() => { }); } if (cursorOutput) { @@ -536,5 +555,6 @@ export { profileBootstrapSignals, profileProject, readLinkedVercelProject, - resolveSessionStartProjectRoot + resolveSessionStartProjectRoot, + trackSessionStartTelemetry }; diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index 403a593..35f7643 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -32,7 +32,7 @@ import { import { pluginRoot, profileCachePath, safeReadJson, writeSessionFile } from "./hook-env.mjs"; import { createLogger, logCaughtError, type Logger } from "./logger.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; -import { trackBaseEvents, getOrCreateDeviceId } from "./telemetry.mjs"; +import { getOrCreateDeviceId, isBaseTelemetryEnabled, trackBaseEvents } from "./telemetry.mjs"; // --------------------------------------------------------------------------- // Types @@ -512,6 +512,35 @@ export function buildSessionStartTelemetryEntries(args: { return entries; } +export async function trackSessionStartTelemetry(args: { + sessionId: string; + projectRoot: string; + likelySkills: string[]; + greenfield: boolean; + cliStatus: VercelCliStatus; + telemetryEnabled?: boolean; + getDeviceId?: () => string; + readProjectLink?: (projectRoot: string) => VercelProjectLink | null; + trackEvents?: typeof trackBaseEvents; +}): Promise { + if (!(args.telemetryEnabled ?? isBaseTelemetryEnabled())) { + return; + } + + const deviceId = (args.getDeviceId ?? getOrCreateDeviceId)(); + const vercelProjectLink = (args.readProjectLink ?? readLinkedVercelProject)(args.projectRoot); + + await (args.trackEvents ?? trackBaseEvents)( + args.sessionId, + buildSessionStartTelemetryEntries({ + deviceId, + likelySkills: args.likelySkills, + greenfield: args.greenfield, + cliStatus: args.cliStatus, + vercelProjectLink, + }), + ); +} // --------------------------------------------------------------------------- // Main entry point — profile the project and write env vars. @@ -557,8 +586,23 @@ export function normalizeSessionStartSessionId(input: SessionStartInput | null): return sessionId || null; } -export function resolveSessionStartProjectRoot(env: NodeJS.ProcessEnv = process.env): string { - return env.CLAUDE_PROJECT_ROOT ?? env.CURSOR_PROJECT_DIR ?? process.cwd(); +function nonEmptySessionStartPath(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + +export function resolveSessionStartProjectRoot( + input: SessionStartInput | null, + env: NodeJS.ProcessEnv = process.env, +): string { + const workspaceRoot = Array.isArray(input?.workspace_roots) + ? input.workspace_roots.find((entry) => typeof entry === "string" && entry.trim() !== "") + : null; + + return nonEmptySessionStartPath(input?.cwd) + ?? workspaceRoot + ?? nonEmptySessionStartPath(env.CLAUDE_PROJECT_ROOT) + ?? nonEmptySessionStartPath(env.CURSOR_PROJECT_DIR) + ?? process.cwd(); } function collectBrokenSkillFrontmatterNames(files: string[]): string[] { @@ -675,7 +719,7 @@ async function main(): Promise { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); - const projectRoot = resolveSessionStartProjectRoot(); + const projectRoot = resolveSessionStartProjectRoot(hookInput); logBrokenSkillFrontmatterSummary(); @@ -755,15 +799,13 @@ async function main(): Promise { // Base telemetry — enabled by default unless VERCEL_PLUGIN_TELEMETRY=off if (sessionId) { - const deviceId = getOrCreateDeviceId(); - const vercelProjectLink = readLinkedVercelProject(projectRoot); - await trackBaseEvents(sessionId, buildSessionStartTelemetryEntries({ - deviceId, + await trackSessionStartTelemetry({ + sessionId, + projectRoot, likelySkills, greenfield: greenfield !== null, cliStatus, - vercelProjectLink, - })).catch(() => {}); + }).catch(() => {}); } if (cursorOutput) { diff --git a/package.json b/package.json index 751e72a..b38f45b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vercel-plugin", - "version": "0.32.0", + "version": "0.32.1", "private": true, "bin": { "vercel-plugin": "src/cli/index.ts" diff --git a/tests/session-start-profiler.test.ts b/tests/session-start-profiler.test.ts index 6541a52..36966e0 100644 --- a/tests/session-start-profiler.test.ts +++ b/tests/session-start-profiler.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { chmodSync, existsSync, @@ -9,7 +9,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { delimiter, join, resolve } from "node:path"; import { readSessionFile } from "../hooks/src/hook-env.mts"; const ROOT = resolve(import.meta.dirname, ".."); @@ -21,7 +21,13 @@ let testSessionId: string; // Helpers // --------------------------------------------------------------------------- -async function runProfiler(env: Record): Promise<{ +async function runProfiler( + env: Record, + options?: { + input?: Record; + nodeArgs?: string[]; + }, +): Promise<{ code: number; stdout: string; stderr: string; @@ -38,14 +44,14 @@ async function runProfiler(env: Record): Promise<{ mergedEnv[key] = value; } - const proc = Bun.spawn([NODE_BIN, PROFILER], { + const proc = Bun.spawn([NODE_BIN, ...(options?.nodeArgs ?? []), PROFILER], { stdin: "pipe", stdout: "pipe", stderr: "pipe", env: mergedEnv, }); - proc.stdin.write(JSON.stringify({ session_id: testSessionId })); + proc.stdin.write(JSON.stringify(options?.input ?? { session_id: testSessionId })); proc.stdin.end(); const code = await proc.exited; @@ -69,12 +75,64 @@ function readGreenfieldState(): string { return readSessionFile(testSessionId, "greenfield"); } -function makeMockCommand(binDir: string, commandName: string, body: string): void { +function makeMockCommand( + binDir: string, + commandName: string, + options: { + stdoutLine?: string; + exitCode?: number; + sleepSeconds?: number; + }, +): void { + if (process.platform === "win32") { + const commandPath = join(binDir, `${commandName}.cmd`); + const lines = ["@echo off"]; + + if (options.sleepSeconds) { + lines.push(`powershell -NoProfile -Command "Start-Sleep -Seconds ${options.sleepSeconds}"`); + } + if (options.stdoutLine) { + lines.push(`echo ${options.stdoutLine}`); + } + + lines.push(`exit /b ${options.exitCode ?? 0}`); + writeFileSync(commandPath, `${lines.join("\r\n")}\r\n`, "utf-8"); + return; + } + + const lines = ["#!/bin/sh"]; + + if (options.sleepSeconds) { + lines.push(`sleep ${options.sleepSeconds}`); + } + if (options.stdoutLine) { + const escapedLine = options.stdoutLine.replace(/'/g, `'\\''`); + lines.push(`printf '%s\\n' '${escapedLine}'`); + } + + lines.push(`exit ${options.exitCode ?? 0}`); + const commandPath = join(binDir, commandName); - writeFileSync(commandPath, `#!/bin/sh\n${body}\n`, "utf-8"); + writeFileSync(commandPath, `${lines.join("\n")}\n`, "utf-8"); chmodSync(commandPath, 0o755); } +function createTelemetryPreload(capturePath: string): string { + const preloadPath = join(tempDir, `mock-fetch-${Math.random().toString(36).slice(2)}.mjs`); + writeFileSync(preloadPath, ` +import { writeFileSync } from "node:fs"; + +globalThis.fetch = async (_url, init) => { + writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ + headers: init?.headers, + body: typeof init?.body === "string" ? init.body : null, + }), "utf-8"); + return new Response(null, { status: 204 }); +}; +`, "utf-8"); + return preloadPath; +} + // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- @@ -570,6 +628,52 @@ describe("session-start-profiler", () => { expect(readGreenfieldState()).toBe("true"); }); + test("uses the hook payload root for linked project telemetry", async () => { + const envProjectDir = join(tempDir, "env-project-root"); + const payloadProjectDir = join(tempDir, "payload-project-root"); + const capturePath = join(tempDir, "session-start-telemetry.json"); + const binDir = join(tempDir, "empty-bin"); + mkdirSync(join(envProjectDir, ".vercel"), { recursive: true }); + mkdirSync(join(payloadProjectDir, ".vercel"), { recursive: true }); + mkdirSync(binDir); + writeFileSync( + join(envProjectDir, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_env", orgId: "team_env" }), + "utf-8", + ); + writeFileSync( + join(payloadProjectDir, ".vercel", "project.json"), + JSON.stringify({ projectId: "prj_payload", orgId: "team_payload" }), + "utf-8", + ); + const preloadPath = createTelemetryPreload(capturePath); + + const result = await runProfiler( + { + CLAUDE_ENV_FILE: envFile, + CLAUDE_PROJECT_ROOT: envProjectDir, + PATH: binDir, + }, + { + input: { + session_id: testSessionId, + cwd: payloadProjectDir, + }, + nodeArgs: ["--import", preloadPath], + }, + ); + + expect(result.code).toBe(0); + const captured = JSON.parse(readFileSync(capturePath, "utf-8")) as { + body: string; + }; + const events = JSON.parse(captured.body) as Array<{ key: string; value: string }>; + expect(events.some((event) => event.key === "session:vercel_project_id" && event.value === "prj_payload")).toBe(true); + expect(events.some((event) => event.key === "session:vercel_org_id" && event.value === "team_payload")).toBe(true); + expect(events.some((event) => event.key === "session:vercel_project_id" && event.value === "prj_env")).toBe(false); + expect(events.some((event) => event.key === "session:vercel_org_id" && event.value === "team_env")).toBe(false); + }); + test("hooks.json registers profiler after seen-skills init", () => { const hooksJson = JSON.parse( readFileSync(join(ROOT, "hooks", "hooks.json"), "utf-8"), @@ -602,13 +706,13 @@ describe("session-start-profiler", () => { const binDir = join(tempDir, "mock-bin"); mkdirSync(projectDir); mkdirSync(binDir); - makeMockCommand(binDir, "vercel", "printf 'Vercel CLI 1.9.0\\n'"); - makeMockCommand(binDir, "npm", "printf '1.10.0\\n'"); + makeMockCommand(binDir, "vercel", { stdoutLine: "Vercel CLI 1.9.0" }); + makeMockCommand(binDir, "npm", { stdoutLine: "1.10.0" }); const result = await runProfiler({ CLAUDE_ENV_FILE: envFile, CLAUDE_PROJECT_ROOT: projectDir, - PATH: `${binDir}:${process.env.PATH || ""}`, + PATH: `${binDir}${delimiter}${process.env.PATH || ""}`, }); expect(result.code).toBe(0); @@ -624,7 +728,7 @@ describe("session-start-profiler", () => { const binDir = join(tempDir, "missing-npm-bin"); mkdirSync(projectDir); mkdirSync(binDir); - makeMockCommand(binDir, "vercel", "printf 'Vercel CLI 44.0.0\\n'"); + makeMockCommand(binDir, "vercel", { stdoutLine: "Vercel CLI 44.0.0" }); const result = await runProfiler({ CLAUDE_ENV_FILE: envFile, @@ -644,13 +748,13 @@ describe("session-start-profiler", () => { const binDir = join(tempDir, "slow-vercel-bin"); mkdirSync(projectDir); mkdirSync(binDir); - makeMockCommand(binDir, "vercel", "sleep 5"); + makeMockCommand(binDir, "vercel", { sleepSeconds: 5 }); const startedAt = Date.now(); const result = await runProfiler({ CLAUDE_ENV_FILE: envFile, CLAUDE_PROJECT_ROOT: projectDir, - PATH: `${binDir}:${process.env.PATH || ""}`, + PATH: `${binDir}${delimiter}${process.env.PATH || ""}`, VERCEL_PLUGIN_LOG_LEVEL: "debug", }); const durationMs = Date.now() - startedAt; @@ -663,7 +767,7 @@ describe("session-start-profiler", () => { test("emits debug logs when swallowed profiler errors occur", async () => { const binDir = join(tempDir, "debug-bin"); mkdirSync(binDir); - makeMockCommand(binDir, "vercel", "exit 1"); + makeMockCommand(binDir, "vercel", { exitCode: 1 }); const result = await runProfiler({ CLAUDE_ENV_FILE: join(tempDir, "missing-dir", "claude.env"), @@ -801,6 +905,15 @@ describe("session-start telemetry helpers (unit)", () => { }); }); + test("ignores malformed linked Vercel project metadata", async () => { + const { readLinkedVercelProject } = await import("../hooks/session-start-profiler.mjs"); + const projectDir = join(tempDir, "unit-vercel-link-malformed"); + mkdirSync(join(projectDir, ".vercel"), { recursive: true }); + writeFileSync(join(projectDir, ".vercel", "project.json"), "{not valid json", "utf-8"); + + expect(readLinkedVercelProject(projectDir)).toBeNull(); + }); + test("ignores incomplete linked Vercel project metadata", async () => { const { readLinkedVercelProject } = await import("../hooks/session-start-profiler.mjs"); const projectDir = join(tempDir, "unit-vercel-link-incomplete"); @@ -854,6 +967,36 @@ describe("session-start telemetry helpers (unit)", () => { expect(withoutLink.find((entry) => entry.key === "session:vercel_project_id")).toBeUndefined(); expect(withoutLink.find((entry) => entry.key === "session:vercel_org_id")).toBeUndefined(); }); + + test("skips linked project lookup when base telemetry is disabled", async () => { + const { trackSessionStartTelemetry } = await import("../hooks/session-start-profiler.mjs"); + let readProjectLinkCalls = 0; + let trackEventsCalls = 0; + + await trackSessionStartTelemetry({ + sessionId: "session-disabled-telemetry", + projectRoot: join(tempDir, "telemetry-off-project"), + likelySkills: ["nextjs"], + greenfield: false, + cliStatus: { + installed: true, + currentVersion: "44.7.3", + needsUpdate: false, + }, + telemetryEnabled: false, + getDeviceId: () => "device_123", + readProjectLink: () => { + readProjectLinkCalls += 1; + return { projectId: "prj_123", orgId: "team_456" }; + }, + trackEvents: async () => { + trackEventsCalls += 1; + }, + }); + + expect(readProjectLinkCalls).toBe(0); + expect(trackEventsCalls).toBe(0); + }); }); describe("logBrokenSkillFrontmatterSummary (unit)", () => { From c373b943b8b916ef37b103383cf1e5af22ed7e2c Mon Sep 17 00:00:00 2001 From: Grappeggia Date: Wed, 8 Apr 2026 08:44:35 -0700 Subject: [PATCH 9/9] trim linked project telemetry follow-up Keep the payload-root and telemetry coverage changes, but drop the dedicated cross-platform CI matrix to avoid adding permanent workflow cost for a narrow fix. --- .github/workflows/ci.yml | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 913d84b..8305653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,29 +34,3 @@ jobs: - name: Test run: bun test - - session-start-profiler-cross-platform: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build hooks - run: bun run build:hooks - - - name: Test linked project telemetry coverage - run: bun test tests/session-start-profiler.test.ts -t "session-start telemetry helpers|uses the hook payload root for linked project telemetry" - - - name: Test session hook platform compatibility - run: bun test hooks/session-hooks-platform-compat.test.ts