Files
hyperframes/packages/cli/src/telemetry/config.ts
T

458 lines
19 KiB
TypeScript

import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { randomUUID } from "node:crypto";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
// ---------------------------------------------------------------------------
// Config directory: ~/.hyperframes/
// ---------------------------------------------------------------------------
const CONFIG_DIR = join(homedir(), ".hyperframes");
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
// ---------------------------------------------------------------------------
// Install-state file: ~/.hyperframes/install-state.json
//
// A separate FILE, but deliberately the same DIRECTORY as config.json, so
// `rm -rf ~/.hyperframes` really is a full reset. It previously lived in
// ~/.local/state/hyperframes/ specifically to survive that delete; review
// rejected that ("if someone is deleting their hyperframes config it should
// wipe all hyperframes state — I'm not sure we should try and persist state
// elsewhere to get around this"), and the measurement agreed: the churn this
// actually defends against is not users running `rm -rf`.
//
// The threat it does defend against is config.json itself. That file is hot
// and wide — ~20 fields rewritten on every command and every render — and
// readConfig recovers from ANY parse/permission/IO failure by minting a fresh
// identity. Splitting these two facts into their own file decouples them from
// that churn: no shared schema to migrate on upgrade, and one write at first
// mint instead of one per command.
//
// It carries exactly two facts, and no identity — no anonymousId, no
// counters, nothing linking the old install to the new one:
//
// 1. `markerAt` — "a hyperframes install existed on this machine". Now that
// it shares CONFIG_DIR, `predecessorFound` measures the churn we care
// about (config.json lost, install-state survived => corruption/re-mint)
// rather than deliberate directory deletion, which takes both.
// 2. `deParallelRouterTrialFired` — the DE parallel-router circuit
// breaker's tripped state, so a config re-mint does not re-enrol an
// install into an experimental path that already FAILED on this machine.
//
// Removal path: delete ~/.hyperframes (or just this file). `hyperframes
// telemetry status` prints its exact location.
// ---------------------------------------------------------------------------
const STATE_FILE = join(CONFIG_DIR, "install-state.json");
// Pre-move location. Read once, migrated, then deleted — an install that
// wrote state under the old scheme keeps its tripped breaker instead of
// silently re-enrolling, and no file is left behind outside CONFIG_DIR.
const LEGACY_STATE_FILE = join(homedir(), ".local", "state", "hyperframes", "install-state.json");
interface InstallState {
/** ISO timestamp of when the marker was first written. */
markerAt: string;
/** Rolled-over circuit-breaker state — see HyperframesConfig's field. */
deParallelRouterTrialFired?: boolean;
}
/** Parse one state file; any parse/shape failure reads as absent. */
function parseInstallState(file: string): InstallState | null {
try {
if (!existsSync(file)) return null;
const parsed = JSON.parse(readFileSync(file, "utf-8")) as Partial<InstallState>;
if (typeof parsed.markerAt !== "string") return null;
return {
markerAt: parsed.markerAt,
deParallelRouterTrialFired: parsed.deParallelRouterTrialFired === true ? true : undefined,
};
} catch {
return null;
}
}
/**
* Read the install-state file, adopting the pre-move copy if this machine
* still has one.
*
* Migration is one-way and best-effort: the current location always wins (a
* stale legacy file must never resurrect a breaker the user has since
* cleared), and failing to delete the legacy copy is not an error — it is
* re-read harmlessly next time.
*/
function readInstallState(): InstallState | null {
const current = parseInstallState(STATE_FILE);
if (current !== null) {
removeLegacyStateFile();
return current;
}
const legacy = parseInstallState(LEGACY_STATE_FILE);
if (legacy === null) return null;
try {
writeInstallState(legacy);
removeLegacyStateFile();
} catch {
// Keep the legacy copy; the value is still returned below either way.
}
return legacy;
}
function removeLegacyStateFile(): void {
try {
if (existsSync(LEGACY_STATE_FILE)) rmSync(LEGACY_STATE_FILE, { force: true });
} catch {
// Best-effort cleanup — never break the CLI over a leftover file.
}
}
// Sync bookkeeping, so the existsSync+read doesn't run on every writeConfig:
// `stateMarkerSynced` = the marker is known present; `stateFiredSynced` = the
// state file is known to already carry fired=true.
let stateMarkerSynced = false;
let stateFiredSynced = false;
/** Test-only: reset the sync memo (module state leaks across vitest cases). */
export function __resetInstallStateSyncForTests(): void {
stateMarkerSynced = false;
stateFiredSynced = false;
}
/**
* Atomic for the same reason writeConfig is: a torn read must never exist,
* since a corrupted state file silently reads as absent.
*/
function writeInstallState(next: InstallState): void {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
const tmpFile = `${STATE_FILE}.${process.pid}.tmp`;
writeFileSync(tmpFile, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
renameSync(tmpFile, STATE_FILE);
}
/**
* Bring the install-state file up to date with this config write: ensure the
* marker exists, and mirror a tripped breaker. Called from `writeConfig` so
* no breaker write site can forget it. Never throws — same contract as the
* rest of this file, telemetry must not break the CLI.
*/
/** What the state file should say after this config write; null = already correct. */
function nextInstallState(state: InstallState | null, wantFired: boolean): InstallState | null {
const hadFired = state?.deParallelRouterTrialFired === true;
if (state !== null && (hadFired || !wantFired)) return null;
// Every path reaching here has hadFired === false (state is either null, or
// the guard above already returned when hadFired was true) — the field is
// simply wantFired, not a merge of the two (review nit, two independent
// reviewers).
return {
markerAt: state?.markerAt ?? new Date().toISOString(),
deParallelRouterTrialFired: wantFired || undefined,
};
}
function syncInstallState(config: HyperframesConfig): void {
const wantFired = config.deParallelRouterTrialFired === true;
if (stateMarkerSynced && (stateFiredSynced || !wantFired)) return;
try {
const state = readInstallState();
const next = nextInstallState(state, wantFired);
if (next !== null) writeInstallState(next);
stateMarkerSynced = true;
stateFiredSynced = wantFired || state?.deParallelRouterTrialFired === true;
} catch {
// Leave the memo unset so a later write retries.
}
}
/**
* Build a brand-new config for an install with no (readable) config file,
* consulting the install-state file for what a previous install on this
* machine left behind.
*/
function mintConfig(): HyperframesConfig {
const state = readInstallState();
return {
...DEFAULT_CONFIG,
anonymousId: randomUUID(),
predecessorFound: state !== null,
// The rollover itself: a breaker tripped by a previous install on this
// machine stays tripped for the new one.
deParallelRouterTrialFired: state?.deParallelRouterTrialFired === true ? true : undefined,
};
}
export interface HyperframesConfig {
/** Whether anonymous telemetry is enabled (default: true in production) */
telemetryEnabled: boolean;
/** Stable anonymous identifier — no PII, just a random UUID */
anonymousId: string;
/** Whether the first-run telemetry notice has been shown */
telemetryNoticeShown: boolean;
/** Total CLI command invocations (for engagement prompts) */
commandCount: number;
/** Total successful renders (for feedback prompt gating) */
renderSuccessCount: number;
/** The renderSuccessCount at which feedback was last shown */
lastFeedbackPromptAt: number;
/** ISO timestamp of the last npm registry version check */
lastUpdateCheck?: string;
/** Latest version found on npm */
latestVersion?: string;
/** Throttle for the non-TTY stale-project-pin notice (ms epoch). */
lastStalePinNoticeAt?: number;
/**
* Auto-update marker. Set when a background install is spawned so a
* subsequent run can skip re-triggering it. Cleared once
* `completedUpdate` captures the outcome.
*/
pendingUpdate?: {
/** Version being installed. */
version: string;
/** Install command being run, for debug logging. */
command: string;
/** ISO timestamp of when the background install was launched. */
startedAt: string;
};
/**
* Outcome of the last completed auto-update, written by the detached
* installer. Surfaced once in the next invocation and then cleared.
*/
completedUpdate?: {
version: string;
/** Whether the install succeeded. */
ok: boolean;
/** ISO timestamp of when the installer finished. */
finishedAt: string;
/** Non-empty when `ok === false` — the installer's stderr tail. */
error?: string;
/** True after the result has been surfaced once to the user. */
reported?: boolean;
};
/** ISO timestamp of the last `skills check` freshness check (24h cache). */
lastSkillsCheck?: string;
/** Whether installed skills were stale at the last check. */
skillsUpdateAvailable?: boolean;
/** How many installed skills were outdated at the last check. */
skillsOutdatedCount?: number;
/** How many skills were missing (not installed) at the last check. */
skillsMissingCount?: number;
/** How many installed skills were flagged removed-upstream at the last check. */
skillsRemovedCount?: number;
/**
* True once the DE parallel-router experiment ("HF_DE_PARALLEL_ROUTER")
* has actually FAILED (its self-verify/generic-failure safety net fired —
* "reverted", not merely "routed") on a render from this install. The CLI
* enables the experiment for free on EVERY eligible render from a fresh
* install — not just once — to maximize real-traffic router telemetry
* (mostly successful "routed" outcomes) without requiring anyone to
* manually opt in via env var; only a real failure turns it off, and only
* for this install going forward. See `renderLocal`'s
* `maybeEnableDeParallelRouterTrial`/`maybeConsumeDeParallelRouterTrial`.
*/
deParallelRouterTrialFired?: boolean;
/**
* Count of engaged (routed or reverted) trial renders so far — the
* backstop that caps exposure even absent an actual failure. See
* `DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS` in `render.ts`.
*/
deParallelRouterTrialRenderCount?: number;
/**
* Whether a previous install's state marker existed on this machine when
* this config was minted. Attached to telemetry so the fraction of fresh
* installs that are RECOVERABLE churn (config wiped, machine persisted) is
* measurable directly. `undefined` on configs minted before this field
* existed — a different fact from `false` (minted fresh, no predecessor).
*/
predecessorFound?: boolean;
/**
* Ring of the last few local renders (newest last). `hyperframes feedback`
* attaches these ids — which are the `render_job_id` /
* `observability_render_job_id` on this install's PostHog events — to the
* feedback it submits, so a wild bug report can be joined to the exact
* telemetry rows of the renders it describes.
*/
recentRenders?: RecentRenderRecord[];
}
/** One entry in {@link HyperframesConfig.recentRenders}. */
export interface RecentRenderRecord {
/** The render job id (`RenderJob.id` — the telemetry `render_job_id`). */
id: string;
/** ISO timestamp of when the render finished. */
at: string;
/** Whether the render completed successfully. */
ok: boolean;
}
/** Ring size for {@link HyperframesConfig.recentRenders}. */
const MAX_RECENT_RENDERS = 5;
/**
* Append a finished render to the recent-renders ring (newest last, capped).
* Fresh read-modify-write like the trial counters — narrows, but does not
* eliminate, lost updates against a concurrent CLI process.
*/
export function recordRecentRender(id: string, ok: boolean): void {
const config = readConfigFresh();
const ring = [...(config.recentRenders ?? []), { id, at: new Date().toISOString(), ok }];
config.recentRenders = ring.slice(-MAX_RECENT_RENDERS);
writeConfig(config);
}
const DEFAULT_CONFIG: HyperframesConfig = {
telemetryEnabled: true,
anonymousId: "",
telemetryNoticeShown: false,
commandCount: 0,
renderSuccessCount: 0,
lastFeedbackPromptAt: 0,
};
let cachedConfig: HyperframesConfig | null = null;
/**
* Read the config file, creating it with defaults if it doesn't exist.
* Returns a mutable copy — call `writeConfig()` to persist changes.
*/
export function readConfig(): HyperframesConfig {
if (cachedConfig) return { ...cachedConfig };
if (!existsSync(CONFIG_FILE)) {
const config = mintConfig();
writeConfig(config);
return config;
}
try {
const raw = readFileSync(CONFIG_FILE, "utf-8");
const parsed = JSON.parse(raw) as Partial<HyperframesConfig>;
const config: HyperframesConfig = {
telemetryEnabled: parsed.telemetryEnabled ?? DEFAULT_CONFIG.telemetryEnabled,
anonymousId: parsed.anonymousId || randomUUID(),
telemetryNoticeShown: parsed.telemetryNoticeShown ?? DEFAULT_CONFIG.telemetryNoticeShown,
commandCount: parsed.commandCount ?? DEFAULT_CONFIG.commandCount,
renderSuccessCount: parsed.renderSuccessCount ?? DEFAULT_CONFIG.renderSuccessCount,
lastFeedbackPromptAt: parsed.lastFeedbackPromptAt ?? DEFAULT_CONFIG.lastFeedbackPromptAt,
lastUpdateCheck: parsed.lastUpdateCheck,
latestVersion: parsed.latestVersion,
lastStalePinNoticeAt: parsed.lastStalePinNoticeAt,
pendingUpdate: parsed.pendingUpdate,
completedUpdate: parsed.completedUpdate,
lastSkillsCheck: parsed.lastSkillsCheck,
skillsUpdateAvailable: parsed.skillsUpdateAvailable,
skillsOutdatedCount: parsed.skillsOutdatedCount,
skillsMissingCount: parsed.skillsMissingCount,
skillsRemovedCount: parsed.skillsRemovedCount,
// Explicit `=== true`/typeof-number checks rather than a truthy/nullish
// read — a hand-edited or corrupted config could plausibly carry a
// non-boolean/non-number JSON value (e.g. the STRING "false", which is
// truthy in JS) for these two fields specifically, since they're read
// with a bare truthy check at the call site (review finding).
deParallelRouterTrialFired: parsed.deParallelRouterTrialFired === true ? true : undefined,
deParallelRouterTrialRenderCount:
typeof parsed.deParallelRouterTrialRenderCount === "number"
? parsed.deParallelRouterTrialRenderCount
: undefined,
predecessorFound:
typeof parsed.predecessorFound === "boolean" ? parsed.predecessorFound : undefined,
recentRenders: Array.isArray(parsed.recentRenders)
? parsed.recentRenders
.filter(
(r): r is RecentRenderRecord =>
typeof r === "object" &&
r !== null &&
typeof (r as RecentRenderRecord).id === "string" &&
typeof (r as RecentRenderRecord).at === "string" &&
typeof (r as RecentRenderRecord).ok === "boolean",
)
.slice(-MAX_RECENT_RENDERS)
: undefined,
};
cachedConfig = config;
return { ...config };
} catch {
// A missing file is handled above. Any failure here means an existing
// preference could not be read safely (corrupt JSON, permissions, I/O).
// Recover through the same mint path as a missing file — so a tripped
// breaker survives config corruption too — but fail closed for the
// privacy control: recovery must never silently turn telemetry back on.
const config = { ...mintConfig(), telemetryEnabled: false };
writeConfig(config);
return config;
}
}
/**
* Re-read the config from disk, bypassing the in-process cache. Use
* immediately before a targeted single-field read-modify-write (e.g. the DE
* parallel-router trial's render count/fired flag) to narrow — though not
* eliminate, there is no cross-process file locking here — the window for a
* lost update against a concurrently-running CLI process that wrote other
* fields in the meantime.
*/
export function readConfigFresh(): HyperframesConfig {
cachedConfig = null;
return readConfig();
}
/**
* Persist config to disk. Updates the in-memory cache on success.
*
* Atomic: writes to a pid-suffixed temp file and renames it over the config —
* `rename(2)` within one directory is atomic on POSIX, so a concurrent
* reader can never observe a partially-written file. That matters beyond
* hygiene: `readConfig`'s corrupted-file catch RESETS the config to defaults
* (new anonymousId, telemetry re-enabled, all optional fields wiped), so a
* torn read of a non-atomic write would silently destroy the user's config
* (review finding).
*
* Returns whether the write actually landed — errors are still swallowed
* (telemetry must never break the CLI), but callers that need persistence
* certainty (e.g. the DE parallel-router trial's off-switch) can react
* 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 };
// Mirror into the install-state file (marker + tripped breaker) so no
// breaker write site has to remember to do it.
syncInstallState(config);
return { ok: true };
} catch (error) {
// Non-fatal — telemetry should never break the CLI
return { ok: false, error: normalizeErrorMessage(error) };
}
}
/**
* Increment the command counter and persist.
*/
export function incrementCommandCount(): number {
const config = readConfig();
config.commandCount++;
writeConfig(config);
return config.commandCount;
}
/** Expose the config directory path for the telemetry command output */
export const CONFIG_PATH = CONFIG_FILE;
/** Expose the install-state path for the telemetry command output. */
export const STATE_PATH = STATE_FILE;