Files
hyperframes/packages/cli/src/commands/telemetry.ts
T
Vance IngallsandClaude Opus 5 dfe92b2aab feat(cli): roll circuit-breaker state over across config wipes
The DE parallel-router breaker's tripped state lived in the same config
file as the install id, so the most common identity reset — deleting
~/.hyperframes — also re-enrolled the machine into an experimental path
that had already failed on it.

Mirror exactly two facts into a machine-local state file
(~/.local/state/hyperframes/install-state.json) that a config wipe does
not touch:

- markerAt: written unconditionally on every install, so the fraction of
  fresh mints that find it directly measures recoverable id churn
  (config wiped, machine persisted) vs unrecoverable (fresh
  machine/container/new user). Emitted as install_predecessor_found on
  telemetry events; absent (not false) on configs predating the field.
- deParallelRouterTrialFired: a breaker tripped by a previous install
  stays tripped for the new one. Config corruption takes the same mint
  path, so it survives that too.

The file deliberately holds NO identity — no anonymousId, no counters.
A wiped config still gets a fresh id unconditionally; only the safety
fact about the machine survives. Sync happens inside writeConfig so no
breaker write site can forget it; failures are swallowed (telemetry
must never break the CLI) but leave the memo unset so a later write
retries. `hyperframes telemetry` lists the state path for transparency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:46:41 -07:00

130 lines
4.7 KiB
TypeScript

import { defineCommand } from "citty";
import {
writeConfigWithResult,
readConfigFresh,
CONFIG_PATH,
STATE_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"],
];
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 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 = 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)}`);
// Machine-local safety state (no identity): survives a config wipe so a
// tripped experiment circuit breaker stays tripped. Listed for transparency.
console.log(` ${c.dim("State:")} ${c.accent(STATE_PATH)}`);
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")} ${c.dim("or")} ${c.accent("DO_NOT_TRACK=1")}`,
);
console.log();
}
export default defineCommand({
meta: { name: "telemetry", description: "Manage anonymous usage telemetry" },
args: {
subcommand: {
type: "positional",
description: "Subcommand: enable, disable, status",
required: false,
},
},
async run({ args }) {
const subcommand = args.subcommand;
if (!subcommand || subcommand === "") {
console.log(`
${c.bold("hyperframes telemetry")} ${c.dim("<subcommand>")}
Manage anonymous usage data collection.
${c.bold("SUBCOMMANDS:")}
${c.accent("status")} ${c.dim("Show current telemetry status")}
${c.accent("enable")} ${c.dim("Enable anonymous telemetry")}
${c.accent("disable")} ${c.dim("Disable anonymous telemetry")}
${c.bold("WHAT WE COLLECT:")}
${c.dim("\u2022")} Command names (init, render, preview, etc.)
${c.dim("\u2022")} Render performance (duration, fps, quality)
${c.dim("\u2022")} Template choices
${c.dim("\u2022")} OS, architecture, Node.js version, CLI version
${c.bold("WHAT WE DON'T COLLECT:")}
${c.dim("\u2022")} File paths, project names, or video content
${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("or")} ${c.accent("DO_NOT_TRACK=1")} ${c.dim("to disable.")}
`);
return;
}
switch (subcommand) {
case "enable":
return setTelemetryEnabled(true);
case "disable":
return setTelemetryEnabled(false);
case "status":
return runStatus();
default:
console.error(
`${c.error("Unknown subcommand:")} ${subcommand}\n\nRun ${c.accent("hyperframes telemetry --help")} for usage.`,
);
failCommand();
}
},
});