mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user