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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 57 additions & 9 deletions hooks/session-start-profiler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
readdirSync,
writeFileSync
} from "fs";
import { homedir } from "os";
import { delimiter, join, resolve } from "path";
import { execFileSync } from "child_process";
import { fileURLToPath } from "url";
Expand All @@ -18,7 +19,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 { trackBaseEvents, getOrCreateDeviceId, isUserIdTelemetryEnabled } from "./telemetry.mjs";
var FILE_MARKERS = [
{ file: "next.config.js", skills: ["nextjs", "turbopack"] },
{ file: "next.config.mjs", skills: ["nextjs", "turbopack"] },
Expand Down Expand Up @@ -297,6 +298,51 @@ function checkVercelCli() {
const needsUpdate = versionComparison === null ? !!(currentVersion && latestVersion && currentVersion !== latestVersion) : versionComparison < 0;
return { installed: true, currentVersion, latestVersion, needsUpdate };
}
function getVercelCliDataDirs(env = process.env, homeDir = homedir()) {
const directories = [];
if (process.platform === "darwin") {
directories.push(join(homeDir, "Library", "Application Support", "com.vercel.cli"));
directories.push(join(homeDir, "Library", "Application Support", "now"));
} else if (process.platform === "win32") {
if (typeof env.APPDATA === "string" && env.APPDATA.trim() !== "") {
directories.push(join(env.APPDATA, "com.vercel.cli"));
directories.push(join(env.APPDATA, "now"));
}
} else {
const xdgDataHome = typeof env.XDG_DATA_HOME === "string" && env.XDG_DATA_HOME.trim() !== "" ? env.XDG_DATA_HOME : join(homeDir, ".local", "share");
directories.push(join(xdgDataHome, "com.vercel.cli"));
directories.push(join(xdgDataHome, "now"));
}
directories.push(join(homeDir, ".now"));
return [...new Set(directories)];
}
function readPersistedVercelCliUserId(env = process.env, homeDir = homedir()) {
for (const configDir of getVercelCliDataDirs(env, homeDir)) {
try {
const parsed = JSON.parse(readFileSync(join(configDir, "auth.json"), "utf8"));
if (typeof parsed.userId === "string" && parsed.userId.trim() !== "") {
return parsed.userId;
}
} catch {
continue;
}
}
return null;
}
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.cliStatus.installed && args.vercelCliUserId) {
entries.push({ key: "session:vercel_cli_user_id", value: args.vercelCliUserId });
}
return entries;
}
function parseSessionStartInput(raw) {
try {
if (!raw.trim()) return null;
Expand Down Expand Up @@ -470,14 +516,14 @@ async function main() {
}
if (sessionId) {
const deviceId = getOrCreateDeviceId();
await trackBaseEvents(sessionId, [
{ 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(() => {
const vercelCliUserId = cliStatus.installed && isUserIdTelemetryEnabled() ? readPersistedVercelCliUserId() : null;
await trackBaseEvents(sessionId, buildSessionStartTelemetryEntries({
deviceId,
likelySkills,
greenfield: greenfield !== null,
cliStatus,
vercelCliUserId
})).catch(() => {
});
}
if (cursorOutput) {
Expand All @@ -493,6 +539,7 @@ if (isSessionStartProfilerEntrypoint) {
export {
buildSessionStartProfilerEnvVars,
buildSessionStartProfilerUserMessages,
buildSessionStartTelemetryEntries,
checkGreenfield,
detectSessionStartPlatform,
formatSessionStartProfilerCursorOutput,
Expand All @@ -501,5 +548,6 @@ export {
parseSessionStartInput,
profileBootstrapSignals,
profileProject,
readPersistedVercelCliUserId,
resolveSessionStartProjectRoot
};
92 changes: 83 additions & 9 deletions hooks/src/session-start-profiler.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { trackBaseEvents, getOrCreateDeviceId, isUserIdTelemetryEnabled } from "./telemetry.mjs";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -312,6 +312,10 @@ interface VercelCliStatus {
needsUpdate: boolean;
}

interface VercelCliAuthConfig {
userId?: unknown;
}

// Subprocess args kept as constants to avoid array literals that confuse the
// validate.ts slug-extraction regex (it scans for `["..."]` patterns).
const VERCEL_VERSION_ARGS: string[] = "--version".split(" ");
Expand Down Expand Up @@ -456,6 +460,74 @@ function checkVercelCli(): VercelCliStatus {
return { installed: true, currentVersion, latestVersion, needsUpdate };
}

function getVercelCliDataDirs(
env: NodeJS.ProcessEnv = process.env,
homeDir: string = homedir(),
): string[] {
const directories: string[] = [];

if (process.platform === "darwin") {
directories.push(join(homeDir, "Library", "Application Support", "com.vercel.cli"));
directories.push(join(homeDir, "Library", "Application Support", "now"));
} else if (process.platform === "win32") {
if (typeof env.APPDATA === "string" && env.APPDATA.trim() !== "") {
directories.push(join(env.APPDATA, "com.vercel.cli"));
directories.push(join(env.APPDATA, "now"));
}
} else {
const xdgDataHome = typeof env.XDG_DATA_HOME === "string" && env.XDG_DATA_HOME.trim() !== ""
? env.XDG_DATA_HOME
: join(homeDir, ".local", "share");
directories.push(join(xdgDataHome, "com.vercel.cli"));
directories.push(join(xdgDataHome, "now"));
}

directories.push(join(homeDir, ".now"));

return [...new Set(directories)];
}

export function readPersistedVercelCliUserId(
env: NodeJS.ProcessEnv = process.env,
homeDir: string = homedir(),
): string | null {
for (const configDir of getVercelCliDataDirs(env, homeDir)) {
try {
const parsed = JSON.parse(readFileSync(join(configDir, "auth.json"), "utf8")) as VercelCliAuthConfig;
if (typeof parsed.userId === "string" && parsed.userId.trim() !== "") {
return parsed.userId;
}
} catch {
continue;
}
}

return null;
}

export function buildSessionStartTelemetryEntries(args: {
deviceId: string;
likelySkills: string[];
greenfield: boolean;
cliStatus: VercelCliStatus;
vercelCliUserId?: string | 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.cliStatus.installed && args.vercelCliUserId) {
entries.push({ key: "session:vercel_cli_user_id", value: args.vercelCliUserId });
}

return entries;
}

// ---------------------------------------------------------------------------
// Main entry point — profile the project and write env vars.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -699,14 +771,16 @@ async function main(): Promise<void> {
// Base telemetry — enabled by default unless VERCEL_PLUGIN_TELEMETRY=off
if (sessionId) {
const deviceId = getOrCreateDeviceId();
await trackBaseEvents(sessionId, [
{ 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(() => {});
const vercelCliUserId = cliStatus.installed && isUserIdTelemetryEnabled()
? readPersistedVercelCliUserId()
: null;
await trackBaseEvents(sessionId, buildSessionStartTelemetryEntries({
deviceId,
likelySkills,
greenfield: greenfield !== null,
cliStatus,
vercelCliUserId,
})).catch(() => {});
}

if (cursorOutput) {
Expand Down
18 changes: 18 additions & 0 deletions hooks/src/telemetry.mts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,24 @@ export function isPromptTelemetryEnabled(env: NodeJS.ProcessEnv = process.env):
}
}

/**
* Account-linked telemetry is allowed unless the user has opted out.
* A global telemetry override disables it entirely, and an explicit
* "disabled" preference blocks persisted Vercel CLI user identifiers.
*/
export function isUserIdTelemetryEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const override = getTelemetryOverride(env);
if (override === "off") return false;

try {
const prefPath = join(homedir(), ".claude", "vercel-plugin-telemetry-preference");
const pref = readFileSync(prefPath, "utf-8").trim();
return pref !== "disabled";
} catch {
return true;
}
}

// ---------------------------------------------------------------------------
// Always-on base telemetry (session, tool, skill injection events)
// ---------------------------------------------------------------------------
Expand Down
12 changes: 12 additions & 0 deletions hooks/telemetry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ function isPromptTelemetryEnabled(env = process.env) {
return false;
}
}
function isUserIdTelemetryEnabled(env = process.env) {
const override = getTelemetryOverride(env);
if (override === "off") return false;
try {
const prefPath = join(homedir(), ".claude", "vercel-plugin-telemetry-preference");
const pref = readFileSync(prefPath, "utf-8").trim();
return pref !== "disabled";
} catch {
return true;
}
}
async function trackBaseEvent(sessionId, key, value) {
if (!isBaseTelemetryEnabled()) return;
const event = {
Expand Down Expand Up @@ -115,6 +126,7 @@ export {
getTelemetryOverride,
isBaseTelemetryEnabled,
isPromptTelemetryEnabled,
isUserIdTelemetryEnabled,
trackBaseEvent,
trackBaseEvents,
trackEvent,
Expand Down
74 changes: 74 additions & 0 deletions tests/session-start-profiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,80 @@ describe("profileProject (unit)", () => {
});
});

describe("session-start telemetry helpers (unit)", () => {
test("reads persisted Vercel CLI userId from auth.json", async () => {
const { readPersistedVercelCliUserId } = await import("../hooks/session-start-profiler.mjs");
const homeDir = join(tempDir, "home");
const env: Record<string, string> = {};
const authDir = process.platform === "win32"
? (() => {
const appData = join(homeDir, "AppData", "Roaming");
env.APPDATA = appData;
return join(appData, "com.vercel.cli");
})()
: process.platform === "darwin"
? join(homeDir, "Library", "Application Support", "com.vercel.cli")
: (() => {
const xdgDataHome = join(homeDir, ".local", "share");
env.XDG_DATA_HOME = xdgDataHome;
return join(xdgDataHome, "com.vercel.cli");
})();
mkdirSync(authDir, { recursive: true });
writeFileSync(
join(authDir, "auth.json"),
JSON.stringify({ token: "redacted", userId: "user_123" }),
"utf-8",
);

expect(readPersistedVercelCliUserId(env, homeDir)).toBe("user_123");
});

test("includes Vercel CLI userId in session telemetry only when present", async () => {
const { buildSessionStartTelemetryEntries } = await import("../hooks/session-start-profiler.mjs");

const withUserId = buildSessionStartTelemetryEntries({
deviceId: "device_123",
likelySkills: ["nextjs", "vercel-cli"],
greenfield: false,
cliStatus: {
installed: true,
currentVersion: "44.7.3",
needsUpdate: false,
},
vercelCliUserId: "user_123",
});
expect(withUserId).toContainEqual({
key: "session:vercel_cli_user_id",
value: "user_123",
});

const withoutUserId = buildSessionStartTelemetryEntries({
deviceId: "device_123",
likelySkills: ["nextjs", "vercel-cli"],
greenfield: false,
cliStatus: {
installed: true,
currentVersion: "44.7.3",
needsUpdate: false,
},
vercelCliUserId: null,
});
expect(withoutUserId.find((entry) => entry.key === "session:vercel_cli_user_id")).toBeUndefined();

const cliMissing = buildSessionStartTelemetryEntries({
deviceId: "device_123",
likelySkills: ["nextjs"],
greenfield: false,
cliStatus: {
installed: false,
needsUpdate: false,
},
vercelCliUserId: "user_123",
});
expect(cliMissing.find((entry) => entry.key === "session:vercel_cli_user_id")).toBeUndefined();
});
});

describe("logBrokenSkillFrontmatterSummary (unit)", () => {
test("emits one summary warning when a skill has malformed frontmatter", async () => {
const { logBrokenSkillFrontmatterSummary } = await import("../hooks/session-start-profiler.mjs");
Expand Down
Loading
Loading