mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): make telemetry opt-out durable (#2852)
* fix(cli): make telemetry opt-out durable * fix(cli): make telemetry status trustworthy
This commit is contained in:
@@ -233,6 +233,8 @@ if (!isHelp && command !== "telemetry" && command !== "events" && command !== "u
|
||||
|
||||
// `events` skips the update check too — a skill-usage beacon must not add
|
||||
// network latency or trigger a background self-upgrade on the calling skill.
|
||||
// `telemetry` skips it because update metadata must never race the command
|
||||
// that changes the user's telemetry preference.
|
||||
// `skills` is excluded from the SKILLS nudge for the same reason `upgrade` is
|
||||
// excluded from the self-update notice: a command that is itself actively
|
||||
// checking/reconciling skills (`skills check`, `skills update`) must not also
|
||||
@@ -244,6 +246,7 @@ if (
|
||||
!hasJsonFlag &&
|
||||
command !== "upgrade" &&
|
||||
command !== "events" &&
|
||||
command !== "telemetry" &&
|
||||
command !== "skills"
|
||||
) {
|
||||
// Report any completed auto-install from the previous run first, before
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const baseConfig = {
|
||||
telemetryEnabled: true,
|
||||
anonymousId: "test-install",
|
||||
telemetryNoticeShown: true,
|
||||
commandCount: 7,
|
||||
renderSuccessCount: 0,
|
||||
lastFeedbackPromptAt: 0,
|
||||
};
|
||||
|
||||
async function loadTelemetryCommand(options?: {
|
||||
writeSucceeds?: boolean;
|
||||
configEnabled?: boolean;
|
||||
devMode?: boolean;
|
||||
apiKey?: string;
|
||||
}) {
|
||||
const config = {
|
||||
...baseConfig,
|
||||
telemetryEnabled: options?.configEnabled ?? true,
|
||||
};
|
||||
const writeConfigWithResult = vi.fn(() =>
|
||||
options?.writeSucceeds === false
|
||||
? { ok: false as const, error: "EACCES: permission denied" }
|
||||
: { ok: true as const },
|
||||
);
|
||||
vi.resetModules();
|
||||
vi.doMock("../telemetry/config.js", () => ({
|
||||
CONFIG_PATH: "/test/.hyperframes/config.json",
|
||||
readConfig: () => {
|
||||
throw new Error("telemetry commands must bypass stale cached config");
|
||||
},
|
||||
readConfigFresh: () => ({ ...config }),
|
||||
writeConfigWithResult,
|
||||
}));
|
||||
vi.doMock("../utils/env.js", () => ({
|
||||
isDevMode: () => options?.devMode ?? false,
|
||||
}));
|
||||
vi.doMock("../telemetry/transport.js", () => ({
|
||||
POSTHOG_API_KEY: options?.apiKey ?? "phc_test",
|
||||
}));
|
||||
const module = await import("./telemetry.js");
|
||||
return { command: module.default, writeConfigWithResult };
|
||||
}
|
||||
|
||||
async function runSubcommand(
|
||||
command: Awaited<ReturnType<typeof loadTelemetryCommand>>["command"],
|
||||
subcommand: string,
|
||||
): Promise<void> {
|
||||
await command.run?.({
|
||||
args: { subcommand },
|
||||
rawArgs: [subcommand],
|
||||
cmd: command,
|
||||
} as never);
|
||||
}
|
||||
|
||||
async function runWithCapturedOutput(
|
||||
command: Awaited<ReturnType<typeof loadTelemetryCommand>>["command"],
|
||||
subcommand: string,
|
||||
): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(" "));
|
||||
});
|
||||
await runSubcommand(command, subcommand);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
describe("telemetry command", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("../telemetry/config.js");
|
||||
vi.doUnmock("../utils/env.js");
|
||||
vi.doUnmock("../telemetry/transport.js");
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
delete process.env["HYPERFRAMES_NO_TELEMETRY"];
|
||||
delete process.env["DO_NOT_TRACK"];
|
||||
});
|
||||
|
||||
it("persists disable from a fresh config snapshot", async () => {
|
||||
const { command, writeConfigWithResult } = await loadTelemetryCommand();
|
||||
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
|
||||
await runSubcommand(command, "disable");
|
||||
|
||||
expect(writeConfigWithResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ telemetryEnabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails instead of claiming success when the preference cannot be persisted", async () => {
|
||||
const { command } = await loadTelemetryCommand({ writeSucceeds: false });
|
||||
const stdout = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
await expect(runSubcommand(command, "disable")).rejects.toMatchObject({
|
||||
name: "CliRuntimeError",
|
||||
});
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(expect.stringContaining("Could not persist"));
|
||||
expect(stderr).toHaveBeenCalledWith(expect.stringContaining("EACCES: permission denied"));
|
||||
expect(stdout).not.toHaveBeenCalledWith(expect.stringContaining("Telemetry disabled"));
|
||||
});
|
||||
|
||||
it("reports the effective env-var opt-out instead of the stored preference", async () => {
|
||||
process.env["HYPERFRAMES_NO_TELEMETRY"] = "1";
|
||||
const { command } = await loadTelemetryCommand({ configEnabled: true });
|
||||
const output = await runWithCapturedOutput(command, "status");
|
||||
expect(output).toContain("disabled");
|
||||
expect(output).toContain("HYPERFRAMES_NO_TELEMETRY");
|
||||
expect(output).toContain("Tracked commands:");
|
||||
});
|
||||
|
||||
it("reports DO_NOT_TRACK as the effective opt-out source", async () => {
|
||||
process.env["DO_NOT_TRACK"] = "1";
|
||||
const { command } = await loadTelemetryCommand({ configEnabled: true });
|
||||
const output = await runWithCapturedOutput(command, "status");
|
||||
expect(output).toContain("DO_NOT_TRACK");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["enable", "Telemetry preference"],
|
||||
["disable", "Telemetry disabled"],
|
||||
])("explains the effective override after telemetry %s", async (subcommand, expectedSuccess) => {
|
||||
process.env["HYPERFRAMES_NO_TELEMETRY"] = "true";
|
||||
const { command } = await loadTelemetryCommand({ configEnabled: subcommand === "disable" });
|
||||
const output = await runWithCapturedOutput(command, subcommand);
|
||||
expect(output).toContain(expectedSuccess);
|
||||
expect(output).toContain("remains disabled");
|
||||
expect(output).toContain("HYPERFRAMES_NO_TELEMETRY");
|
||||
});
|
||||
|
||||
it("reports dev mode as the effective source", async () => {
|
||||
const { command } = await loadTelemetryCommand({ devMode: true });
|
||||
const output = await runWithCapturedOutput(command, "status");
|
||||
expect(output).toContain("disabled");
|
||||
expect(output).toContain("dev_mode");
|
||||
});
|
||||
|
||||
it("reports a telemetry-disabled build as the effective source", async () => {
|
||||
const { command } = await loadTelemetryCommand({ apiKey: "disabled" });
|
||||
const output = await runWithCapturedOutput(command, "status");
|
||||
expect(output).toContain("disabled");
|
||||
expect(output).toContain("telemetry_disabled_build");
|
||||
});
|
||||
});
|
||||
@@ -1,39 +1,67 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { writeConfigWithResult, readConfigFresh, CONFIG_PATH } from "../telemetry/config.js";
|
||||
import { effectiveTelemetryStatus, type TelemetryStatusSource } from "../telemetry/policy.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Check current telemetry status", "hyperframes telemetry status"],
|
||||
["Disable telemetry", "hyperframes telemetry disable"],
|
||||
["Enable telemetry", "hyperframes telemetry enable"],
|
||||
];
|
||||
import { readConfig, writeConfig, CONFIG_PATH } from "../telemetry/config.js";
|
||||
|
||||
function runEnable(): void {
|
||||
const config = readConfig();
|
||||
config.telemetryEnabled = true;
|
||||
writeConfig(config);
|
||||
console.log(`\n ${c.success("\u2713")} Telemetry ${c.success("enabled")}\n`);
|
||||
function describeOverride(source: Exclude<TelemetryStatusSource, "config">): string {
|
||||
switch (source) {
|
||||
case "HYPERFRAMES_NO_TELEMETRY":
|
||||
case "DO_NOT_TRACK":
|
||||
return `${source} is set`;
|
||||
case "dev_mode":
|
||||
return "this is a development build";
|
||||
case "telemetry_disabled_build":
|
||||
return "this build has no telemetry key";
|
||||
}
|
||||
}
|
||||
|
||||
function runDisable(): void {
|
||||
const config = readConfig();
|
||||
config.telemetryEnabled = false;
|
||||
writeConfig(config);
|
||||
console.log(`\n ${c.success("\u2713")} Telemetry ${c.bold("disabled")}\n`);
|
||||
function setTelemetryEnabled(enabled: boolean): void {
|
||||
// Bypass the module cache so this privacy preference starts from the latest
|
||||
// on-disk state rather than a snapshot held by another config consumer.
|
||||
const config = readConfigFresh();
|
||||
config.telemetryEnabled = enabled;
|
||||
const result = writeConfigWithResult(config);
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
`\n ${c.error("\u2717")} Could not persist telemetry preference to ${c.accent(CONFIG_PATH)}\n` +
|
||||
` ${c.dim("Reason:")} ${result.error}\n`,
|
||||
);
|
||||
failCommand();
|
||||
}
|
||||
const effective = effectiveTelemetryStatus(enabled);
|
||||
const preference = enabled ? c.success("enabled") : c.bold("disabled");
|
||||
const noun = enabled && !effective.enabled ? "Telemetry preference" : "Telemetry";
|
||||
console.log(`\n ${c.success("\u2713")} ${noun} ${preference}`);
|
||||
if (effective.source !== "config") {
|
||||
console.log(
|
||||
` ${c.dim("Note:")} Telemetry remains disabled because ${describeOverride(effective.source)}.`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function runStatus(): void {
|
||||
const config = readConfig();
|
||||
const status = config.telemetryEnabled ? c.success("enabled") : c.dim("disabled");
|
||||
const config = readConfigFresh();
|
||||
const effective = effectiveTelemetryStatus(config.telemetryEnabled);
|
||||
const status = effective.enabled ? c.success("enabled") : c.dim("disabled");
|
||||
console.log();
|
||||
console.log(` ${c.dim("Status:")} ${status}`);
|
||||
console.log(` ${c.dim("Source:")} ${effective.source}`);
|
||||
console.log(` ${c.dim("Config:")} ${c.accent(CONFIG_PATH)}`);
|
||||
console.log(` ${c.dim("Commands:")} ${c.bold(String(config.commandCount))}`);
|
||||
console.log(` ${c.dim("Tracked commands:")} ${c.bold(String(config.commandCount))}`);
|
||||
console.log();
|
||||
console.log(` ${c.dim("Disable:")} ${c.accent("hyperframes telemetry disable")}`);
|
||||
console.log(` ${c.dim("Env var:")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")}`);
|
||||
console.log(
|
||||
` ${c.dim("Env var:")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")} ${c.dim("or")} ${c.accent("DO_NOT_TRACK=1")}`,
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -71,16 +99,16 @@ ${c.bold("WHAT WE DON'T COLLECT:")}
|
||||
${c.dim("\u2022")} IP addresses (discarded by our analytics provider)
|
||||
${c.dim("\u2022")} Any personally identifiable information
|
||||
|
||||
${c.dim("You can also set")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")} ${c.dim("to disable.")}
|
||||
${c.dim("You can also set")} ${c.accent("HYPERFRAMES_NO_TELEMETRY=1")} ${c.dim("or")} ${c.accent("DO_NOT_TRACK=1")} ${c.dim("to disable.")}
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (subcommand) {
|
||||
case "enable":
|
||||
return runEnable();
|
||||
return setTelemetryEnabled(true);
|
||||
case "disable":
|
||||
return runDisable();
|
||||
return setTelemetryEnabled(false);
|
||||
case "status":
|
||||
return runStatus();
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
async function loadShouldTrack(env: { HYPERFRAMES_NO_TELEMETRY?: string; DO_NOT_TRACK?: string }) {
|
||||
vi.resetModules();
|
||||
vi.doMock("../utils/env.js", () => ({ isDevMode: () => false }));
|
||||
vi.doMock("./config.js", () => ({
|
||||
readConfig: () => ({ telemetryEnabled: true }),
|
||||
writeConfig: () => true,
|
||||
}));
|
||||
vi.stubEnv("HYPERFRAMES_NO_TELEMETRY", env.HYPERFRAMES_NO_TELEMETRY ?? "");
|
||||
vi.stubEnv("DO_NOT_TRACK", env.DO_NOT_TRACK ?? "");
|
||||
return (await import("./client.js")).shouldTrack;
|
||||
}
|
||||
|
||||
describe("telemetry client policy", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.doUnmock("../utils/env.js");
|
||||
vi.doUnmock("./config.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["HYPERFRAMES_NO_TELEMETRY", "true"],
|
||||
["HYPERFRAMES_NO_TELEMETRY", " yes "],
|
||||
["DO_NOT_TRACK", "TRUE"],
|
||||
["DO_NOT_TRACK", "on"],
|
||||
] as const)("does not track when %s=%j", async (name, value) => {
|
||||
const shouldTrack = await loadShouldTrack({ [name]: value });
|
||||
expect(shouldTrack()).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks when the config is enabled and no runtime override applies", async () => {
|
||||
const shouldTrack = await loadShouldTrack({});
|
||||
expect(shouldTrack()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,9 @@ import { readConfig, writeConfig } from "./config.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { diag } from "../ui/diagnostics.js";
|
||||
import { isDevMode } from "../utils/env.js";
|
||||
import { getSystemMeta } from "./system.js";
|
||||
import { enqueue, POSTHOG_API_KEY, type EventProperties } from "./transport.js";
|
||||
import { enqueue, type EventProperties } from "./transport.js";
|
||||
import { telemetryRuntimeOverride } from "./policy.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI-facing telemetry policy: opt-out checks, system-metadata enrichment, and
|
||||
@@ -21,23 +21,13 @@ let telemetryEnabled: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Check if telemetry should be active.
|
||||
* Disabled when: dev mode, user opted out, CI environment, or HYPERFRAMES_NO_TELEMETRY set.
|
||||
* Disabled when: a privacy env var is set, this is a development or
|
||||
* telemetry-disabled build, or the persisted preference is off.
|
||||
*/
|
||||
export function shouldTrack(): boolean {
|
||||
if (telemetryEnabled !== null) return telemetryEnabled;
|
||||
|
||||
if (process.env["HYPERFRAMES_NO_TELEMETRY"] === "1" || process.env["DO_NOT_TRACK"] === "1") {
|
||||
telemetryEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDevMode()) {
|
||||
telemetryEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Safety check: ensure the API key has been configured (phc_ prefix = valid PostHog key)
|
||||
if (!POSTHOG_API_KEY.startsWith("phc_")) {
|
||||
if (telemetryRuntimeOverride() !== null) {
|
||||
telemetryEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module,
|
||||
let readConfig: typeof import("./config.js").readConfig;
|
||||
let readConfigFresh: typeof import("./config.js").readConfigFresh;
|
||||
let writeConfig: typeof import("./config.js").writeConfig;
|
||||
let writeConfigWithResult: typeof import("./config.js").writeConfigWithResult;
|
||||
let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -42,7 +43,8 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module,
|
||||
// module-scoped, so without this, a later test would silently inherit
|
||||
// an earlier test's cached read.
|
||||
vi.resetModules();
|
||||
({ readConfig, readConfigFresh, writeConfig, CONFIG_PATH } = await import("./config.js"));
|
||||
({ readConfig, readConfigFresh, writeConfig, writeConfigWithResult, CONFIG_PATH } =
|
||||
await import("./config.js"));
|
||||
});
|
||||
|
||||
it("creates a default config with a fresh anonymousId when no file exists", () => {
|
||||
@@ -94,11 +96,14 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module,
|
||||
expect(fresh.deParallelRouterTrialRenderCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resets to defaults with a fresh anonymousId when the file is corrupted JSON", () => {
|
||||
it("fails closed when the file is corrupted instead of silently re-enabling telemetry", () => {
|
||||
fsState.files.set(CONFIG_PATH, "{not valid json");
|
||||
const config = readConfig();
|
||||
expect(config.telemetryEnabled).toBe(true);
|
||||
expect(config.telemetryEnabled).toBe(false);
|
||||
expect(config.anonymousId).toBeTruthy();
|
||||
expect(JSON.parse(fsState.files.get(CONFIG_PATH) ?? "{}")).toMatchObject({
|
||||
telemetryEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("writeConfig reports success, leaves no temp file behind, and reports failure when the fs throws", async () => {
|
||||
@@ -114,5 +119,13 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module,
|
||||
throw new Error("EACCES: permission denied");
|
||||
});
|
||||
expect(writeConfig(config)).toBe(false);
|
||||
|
||||
vi.mocked(fs.writeFileSync).mockImplementationOnce(() => {
|
||||
throw new Error("ENOSPC: disk full");
|
||||
});
|
||||
expect(writeConfigWithResult(config)).toEqual({
|
||||
ok: false,
|
||||
error: "ENOSPC: disk full",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config directory: ~/.hyperframes/
|
||||
@@ -192,8 +193,16 @@ export function readConfig(): HyperframesConfig {
|
||||
cachedConfig = config;
|
||||
return { ...config };
|
||||
} catch {
|
||||
// Corrupted config — reset
|
||||
const config = { ...DEFAULT_CONFIG, anonymousId: randomUUID() };
|
||||
// A missing file is handled above. Any failure here means an existing
|
||||
// preference could not be read safely (corrupt JSON, permissions, I/O).
|
||||
// Preserve the historical recovery behavior for the rest of the config,
|
||||
// but fail closed for the privacy control: recovery must never silently
|
||||
// turn telemetry back on.
|
||||
const config = {
|
||||
...DEFAULT_CONFIG,
|
||||
telemetryEnabled: false,
|
||||
anonymousId: randomUUID(),
|
||||
};
|
||||
writeConfig(config);
|
||||
return config;
|
||||
}
|
||||
@@ -229,16 +238,26 @@ export function readConfigFresh(): HyperframesConfig {
|
||||
* instead of re-implementing read-back verification.
|
||||
*/
|
||||
export function writeConfig(config: HyperframesConfig): boolean {
|
||||
return writeConfigWithResult(config).ok;
|
||||
}
|
||||
|
||||
export type ConfigWriteResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Persist config and retain the failure reason for user-facing commands that
|
||||
* must distinguish a durable preference write from a best-effort update.
|
||||
*/
|
||||
export function writeConfigWithResult(config: HyperframesConfig): ConfigWriteResult {
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
const tmpFile = `${CONFIG_FILE}.${process.pid}.tmp`;
|
||||
writeFileSync(tmpFile, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
||||
renameSync(tmpFile, CONFIG_FILE);
|
||||
cachedConfig = { ...config };
|
||||
return true;
|
||||
} catch {
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
// Non-fatal — telemetry should never break the CLI
|
||||
return false;
|
||||
return { ok: false, error: normalizeErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
async function loadPolicy(options?: { devMode?: boolean; apiKey?: string }) {
|
||||
vi.resetModules();
|
||||
vi.doMock("../utils/env.js", () => ({
|
||||
isDevMode: () => options?.devMode ?? false,
|
||||
}));
|
||||
vi.doMock("./transport.js", () => ({
|
||||
POSTHOG_API_KEY: options?.apiKey ?? "phc_test",
|
||||
}));
|
||||
return import("./policy.js");
|
||||
}
|
||||
|
||||
describe("telemetry policy", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("../utils/env.js");
|
||||
vi.doUnmock("./transport.js");
|
||||
vi.resetModules();
|
||||
delete process.env["HYPERFRAMES_NO_TELEMETRY"];
|
||||
delete process.env["DO_NOT_TRACK"];
|
||||
});
|
||||
|
||||
it.each(["1", "true", "TRUE", " yes ", "on"])(
|
||||
"treats %j as an explicit environment opt-out",
|
||||
async (value) => {
|
||||
process.env["HYPERFRAMES_NO_TELEMETRY"] = value;
|
||||
const { effectiveTelemetryStatus } = await loadPolicy();
|
||||
expect(effectiveTelemetryStatus(true).source).toBe("HYPERFRAMES_NO_TELEMETRY");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["", "0", "false", "no", "off", "anything"])(
|
||||
"does not treat %j as an affirmative opt-out value",
|
||||
async (value) => {
|
||||
process.env["HYPERFRAMES_NO_TELEMETRY"] = value;
|
||||
const { effectiveTelemetryStatus } = await loadPolicy();
|
||||
expect(effectiveTelemetryStatus(true)).toEqual({ enabled: true, source: "config" });
|
||||
},
|
||||
);
|
||||
|
||||
it("reports HYPERFRAMES_NO_TELEMETRY as the effective source", async () => {
|
||||
process.env["HYPERFRAMES_NO_TELEMETRY"] = "true";
|
||||
const { effectiveTelemetryStatus } = await loadPolicy();
|
||||
expect(effectiveTelemetryStatus(true)).toEqual({
|
||||
enabled: false,
|
||||
source: "HYPERFRAMES_NO_TELEMETRY",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports DO_NOT_TRACK as the effective source", async () => {
|
||||
process.env["DO_NOT_TRACK"] = "yes";
|
||||
const { effectiveTelemetryStatus } = await loadPolicy();
|
||||
expect(effectiveTelemetryStatus(true)).toEqual({
|
||||
enabled: false,
|
||||
source: "DO_NOT_TRACK",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports dev mode instead of claiming telemetry is active", async () => {
|
||||
const { effectiveTelemetryStatus } = await loadPolicy({ devMode: true });
|
||||
expect(effectiveTelemetryStatus(true)).toEqual({
|
||||
enabled: false,
|
||||
source: "dev_mode",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a telemetry-disabled build instead of claiming telemetry is active", async () => {
|
||||
const { effectiveTelemetryStatus } = await loadPolicy({ apiKey: "disabled" });
|
||||
expect(effectiveTelemetryStatus(true)).toEqual({
|
||||
enabled: false,
|
||||
source: "telemetry_disabled_build",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the persisted preference when no runtime override applies", async () => {
|
||||
const { effectiveTelemetryStatus } = await loadPolicy();
|
||||
expect(effectiveTelemetryStatus(false)).toEqual({ enabled: false, source: "config" });
|
||||
expect(effectiveTelemetryStatus(true)).toEqual({ enabled: true, source: "config" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { isDevMode } from "../utils/env.js";
|
||||
import { POSTHOG_API_KEY } from "./transport.js";
|
||||
|
||||
export type TelemetryStatusSource =
|
||||
| "config"
|
||||
| "HYPERFRAMES_NO_TELEMETRY"
|
||||
| "DO_NOT_TRACK"
|
||||
| "dev_mode"
|
||||
| "telemetry_disabled_build";
|
||||
|
||||
export type TelemetryStatus = {
|
||||
enabled: boolean;
|
||||
source: TelemetryStatusSource;
|
||||
};
|
||||
|
||||
const ENV_OPT_OUT_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
/**
|
||||
* Parse privacy-control environment variables consistently at every CLI
|
||||
* surface. Be liberal about common affirmative spellings so an explicit
|
||||
* opt-out never silently becomes an opt-in.
|
||||
*/
|
||||
function isEnvOptOutValue(value: string | undefined): boolean {
|
||||
return value !== undefined && ENV_OPT_OUT_VALUES.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the runtime/build override that suppresses telemetry before the
|
||||
* persisted preference is considered.
|
||||
*/
|
||||
export function telemetryRuntimeOverride(): Exclude<TelemetryStatusSource, "config"> | null {
|
||||
if (isEnvOptOutValue(process.env["HYPERFRAMES_NO_TELEMETRY"])) {
|
||||
return "HYPERFRAMES_NO_TELEMETRY";
|
||||
}
|
||||
if (isEnvOptOutValue(process.env["DO_NOT_TRACK"])) {
|
||||
return "DO_NOT_TRACK";
|
||||
}
|
||||
if (isDevMode()) {
|
||||
return "dev_mode";
|
||||
}
|
||||
if (!POSTHOG_API_KEY.startsWith("phc_")) {
|
||||
return "telemetry_disabled_build";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single effective-status policy shared by the user-facing command and
|
||||
* the event emitter. This prevents `telemetry status` from disagreeing with
|
||||
* whether events can actually be sent.
|
||||
*/
|
||||
export function effectiveTelemetryStatus(configEnabled: boolean): TelemetryStatus {
|
||||
const override = telemetryRuntimeOverride();
|
||||
if (override !== null) return { enabled: false, source: override };
|
||||
return { enabled: configEnabled, source: "config" };
|
||||
}
|
||||
@@ -130,6 +130,7 @@ async function checkWith(registryVersion: unknown): Promise<{
|
||||
const writes: Array<Record<string, unknown>> = [];
|
||||
vi.doMock("../telemetry/config.js", () => ({
|
||||
readConfig: () => ({}),
|
||||
readConfigFresh: () => ({}),
|
||||
writeConfig: (c: Record<string, unknown>) => writes.push({ ...c }),
|
||||
}));
|
||||
const origFetch = globalThis.fetch;
|
||||
@@ -150,6 +151,49 @@ async function checkWith(registryVersion: unknown): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAcrossConcurrentConfigWrite(): Promise<Record<string, unknown>> {
|
||||
vi.resetModules();
|
||||
const initial = {
|
||||
telemetryEnabled: true,
|
||||
anonymousId: "test-install",
|
||||
telemetryNoticeShown: true,
|
||||
commandCount: 0,
|
||||
renderSuccessCount: 0,
|
||||
lastFeedbackPromptAt: 0,
|
||||
};
|
||||
let persisted: Record<string, unknown> = { ...initial };
|
||||
vi.doMock("../telemetry/config.js", () => ({
|
||||
readConfig: () => ({ ...initial }),
|
||||
readConfigFresh: () => ({ ...persisted }),
|
||||
writeConfig: (config: Record<string, unknown>) => {
|
||||
persisted = { ...config };
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
const origFetch = globalThis.fetch;
|
||||
let releaseResponse: (() => void) | undefined;
|
||||
const responseReady = new Promise<void>((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
globalThis.fetch = (async () => {
|
||||
await responseReady;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ version: "9.9.9" }),
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
try {
|
||||
const mod = await import("./updateCheck.js");
|
||||
const check = mod.checkForUpdate(true);
|
||||
persisted = { ...persisted, telemetryEnabled: false };
|
||||
releaseResponse?.();
|
||||
await check;
|
||||
return persisted;
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* U5: validate/inspect/layout are deprecated in favor of `check`. withMeta's
|
||||
* optional `{ deprecated: true }` is the single place that adds `_meta.deprecated`
|
||||
@@ -236,4 +280,12 @@ describe("checkForUpdate — registry boundary guard", () => {
|
||||
expect(typeof latest).toBe("string");
|
||||
expect(wroteVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges update metadata into a fresh snapshot without re-enabling telemetry", async () => {
|
||||
const persisted = await checkAcrossConcurrentConfigWrite();
|
||||
expect(persisted).toMatchObject({
|
||||
telemetryEnabled: false,
|
||||
latestVersion: "9.9.9",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { compareVersions } from "compare-versions";
|
||||
import { readConfig, writeConfig } from "../telemetry/config.js";
|
||||
import { readConfig, readConfigFresh, writeConfig } from "../telemetry/config.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { isDevMode } from "./env.js";
|
||||
import { detectInstaller } from "./installerDetection.js";
|
||||
@@ -88,9 +88,14 @@ export async function checkForUpdate(force?: boolean): Promise<UpdateCheckResult
|
||||
}
|
||||
const latest = data.version;
|
||||
|
||||
config.lastUpdateCheck = new Date().toISOString();
|
||||
config.latestVersion = latest;
|
||||
writeConfig(config);
|
||||
// The registry request can take seconds. Merge its two metadata fields
|
||||
// into a fresh snapshot rather than writing the object captured before
|
||||
// the network call: the stale object previously reverted a concurrent
|
||||
// `telemetry disable` back to the default `true`.
|
||||
const freshConfig = readConfigFresh();
|
||||
freshConfig.lastUpdateCheck = new Date().toISOString();
|
||||
freshConfig.latestVersion = latest;
|
||||
writeConfig(freshConfig);
|
||||
|
||||
return { current: VERSION, latest, updateAvailable: isNewerSemver(latest, VERSION) };
|
||||
} catch {
|
||||
|
||||
@@ -56,10 +56,19 @@ describe("studio client shouldTrack", () => {
|
||||
expect(shouldTrack()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when VITE_HYPERFRAMES_NO_TELEMETRY='true'", async () => {
|
||||
setNoTelemetry("true");
|
||||
it.each(["true", "TRUE", " yes ", "on"])(
|
||||
"returns false when VITE_HYPERFRAMES_NO_TELEMETRY=%j",
|
||||
async (value) => {
|
||||
setNoTelemetry(value);
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
expect(shouldTrack()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not opt out for an explicit false value", async () => {
|
||||
setNoTelemetry("false");
|
||||
const shouldTrack = await loadShouldTrack();
|
||||
expect(shouldTrack()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false in vite dev mode", async () => {
|
||||
|
||||
@@ -34,12 +34,13 @@ function isApiKeyConfigured(): boolean {
|
||||
|
||||
// VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
|
||||
// opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio
|
||||
// bundle the same way. Vite injects it at build time. Accepts "1" or "true".
|
||||
// bundle the same way. Vite injects it at build time. Match the CLI's
|
||||
// affirmative privacy-control spellings.
|
||||
// `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack).
|
||||
function isBuildTimeOptOut(): boolean {
|
||||
try {
|
||||
const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
|
||||
return v === "1" || v === "true";
|
||||
return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user