mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
* feat(telemetry): measure which lint rules fire, cost, and fail to converge Lint rule changes are currently argued from anecdote. This adds the three measurements needed to argue them from data. `lint_report`, once per `hyperframes lint` or `hyperframes check`: - `code_counts` / `codes` — which rules actually fire, and how often - `rule_group_ms` — milliseconds per rule-source module (core, gsap, media, ...) - `slowest_rule` / `slowest_rule_ms` — slowest single rule as `<group>#<index>` - `rule_count` — how many rules this build ran `lint_rule_streak`, once per finding that survives an edit to its file: - `edits` — how many edits the finding survived - `cleared` — whether it eventually went away The streak event is the one that matters. A lint pass costs about 5ms, so per-rule CPU is not what makes the authoring loop slow; a rule an agent cannot satisfy is, because every failed attempt costs a full edit-and-relint cycle. A single run cannot see that, so `lint_rule_streak` reconstructs it across runs: high `edits` with `cleared: false` is a rule nobody can fix, and the `cleared: true` distribution is the baseline to judge it against. An iteration is counted only when the file's content digest CHANGED and the finding is still there. Re-linting an untouched project is not an attempt, which is what stops `check` (which lints on every invocation) from inflating the numbers. Rule identity is the source module plus an index within it. Naming all 86 rules would make the timings prettier but it is a refactor this measurement does not need: the group locates the file, and the index locates the rule. Version, agent runtime, CI flag, and invocation id are already attached to every event by `trackEvent`, so lint pain can be split by CLI version and by which agent produced it without adding anything here. Privacy: only rule codes, counts, and timings are sent. Streak state lives in ~/.hyperframes/lint-streaks.json alongside config.json (so `rm -rf ~/.hyperframes` is still a full reset) and stores digests only — no file paths, no project names, no composition source. Nothing is written and nothing is emitted when telemetry is off. Entries expire after 14 days and are capped at 500 files. `EventProperties` gains string arrays and numeric maps. `codes` and `code_counts` are inherently a set and a histogram; flattening them into dynamic top-level keys would make them unqueryable. PostHog stores both natively. `trackLintRun` is the single call site shared by `lint` and `check`, and it swallows every error — telemetry must never turn a green lint red. * feat(telemetry): emit per-group rule counts so slowest_rule stays comparable Review catch on #3367: `slowest_rule` is the one positional key in either event. It is `<group>#<index>`, so adding or removing a rule renumbers every later slot in that group and the same string means different rules in two builds. #3366 does exactly that to 34 of 81 surviving slots, and `rule_count` alone says only THAT the ruleset moved, not which groups. `rule_group_counts` carries the per-group sizes alongside it, so a consumer comparing two builds can tell which groups' indices still mean the same thing without anyone having to remember which release dropped rules. `codes`, `code_counts` and `rule_group_ms` are keyed by name and were never affected. Also corrects the rule count in the RULE_GROUPS comment: 86, not ~60, as LINT_RULE_COUNT in the same file computes.
166 lines
6.4 KiB
TypeScript
166 lines
6.4 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { POSTHOG_API_KEY } from "./posthogKey.js";
|
|
import { readConfig } from "./config.js";
|
|
|
|
// This is a public project API key — safe to embed in client-side code.
|
|
// It only allows writing events, not reading data.
|
|
|
|
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
const FLUSH_TIMEOUT_MS = 5_000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lightweight PostHog transport — talks to the HTTP batch API directly to
|
|
// avoid pulling in the full posthog-node SDK and its dependencies. Owns the
|
|
// in-memory event queue and the two delivery paths: the async `flush()` used
|
|
// during a live process, and the exit-time `flushSync()` that hands the queue
|
|
// to a detached child which outlives the parent.
|
|
//
|
|
// This is the reliability-critical layer — telemetry must never break the CLI,
|
|
// and events must survive the render command's abrupt `process.exit()` teardown
|
|
// (see `flush()` for the exit-race that made this subtle). The CLI-facing policy
|
|
// (opt-out, system-metadata enrichment, first-run notice) lives in client.ts.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Scalars cover almost every event. String arrays and numeric maps are allowed
|
|
// too because some facts are inherently a set (which lint rule codes fired) or
|
|
// a histogram (how many findings per code), and flattening those into dynamic
|
|
// top-level keys would make them unqueryable. PostHog stores both natively:
|
|
// `arrayJoin(properties.codes)` for the array, `JSONExtractInt` for the map.
|
|
export type EventPropertyValue =
|
|
| string
|
|
| number
|
|
| boolean
|
|
| null
|
|
| undefined
|
|
| readonly string[]
|
|
| Readonly<Record<string, number>>;
|
|
|
|
export interface EventProperties {
|
|
[key: string]: EventPropertyValue;
|
|
}
|
|
|
|
interface QueuedEvent {
|
|
// Client-generated event id. PostHog dedupes on it, so an event that gets
|
|
// sent by an interrupted flush() AND re-sent by the exit-time flushSync()
|
|
// fallback still counts once.
|
|
uuid: string;
|
|
event: string;
|
|
properties: EventProperties;
|
|
timestamp: string;
|
|
// Override for the batch distinct_id. Defaults to the install's anonymousId.
|
|
// Used to attribute server-side studio renders to the browser user who
|
|
// triggered them, so the render funnel is joinable across processes.
|
|
distinctId?: string;
|
|
}
|
|
|
|
let eventQueue: QueuedEvent[] = [];
|
|
|
|
/**
|
|
* Append an event to the in-memory queue, stamping it with a client-generated
|
|
* `uuid` (PostHog's dedup key) and an ISO timestamp. Non-blocking; the caller
|
|
* is responsible for enrichment (system metadata, cli_version, …).
|
|
*/
|
|
export function enqueue(event: string, properties: EventProperties, distinctId?: string): void {
|
|
eventQueue.push({
|
|
uuid: randomUUID(),
|
|
event,
|
|
distinctId,
|
|
properties,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serialize events into a PostHog `/batch/` payload string. Pure — the queue
|
|
* is untouched, so callers decide when events count as delivered.
|
|
*
|
|
* Each event carries its client-generated `uuid`, which PostHog treats as the
|
|
* event id — re-sending the same event is idempotent, not a duplicate.
|
|
*
|
|
* $ip:null tells PostHog not to record the request IP for any of these events.
|
|
* Server-side "Discard client IP data" is also enabled in project settings.
|
|
*/
|
|
function buildPayload(events: readonly QueuedEvent[]): string | null {
|
|
if (events.length === 0) return null;
|
|
const config = readConfig();
|
|
const batch = events.map((e) => ({
|
|
uuid: e.uuid,
|
|
event: e.event,
|
|
properties: { ...e.properties, $ip: null },
|
|
distinct_id: e.distinctId ?? config.anonymousId,
|
|
timestamp: e.timestamp,
|
|
}));
|
|
return JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
|
|
}
|
|
|
|
/**
|
|
* Flush all queued events to PostHog via async HTTP POST.
|
|
* Call sites: the `beforeExit` hook in cli.ts (normal exit), eager sends right
|
|
* after high-value events (trackRenderComplete / trackRenderError), and the
|
|
* `events` beacon command, which awaits delivery before its process exits.
|
|
*
|
|
* Events are only removed from the queue once the request has completed.
|
|
* The old drain-first version silently lost the whole batch whenever the
|
|
* process died with the fetch in flight — which is the NORMAL exit path for
|
|
* `render`: an agent pipe closing triggers the EPIPE `process.exit(0)`, and
|
|
* error paths call `process.exit(1)` directly, both killing the in-flight
|
|
* request that `beforeExit` had just started. Keeping the queue intact until
|
|
* delivery lets the exit-time flushSync() child (which survives the parent)
|
|
* re-send anything unconfirmed; event uuids make that re-send idempotent.
|
|
*/
|
|
export async function flush(): Promise<void> {
|
|
// Copy, not alias — events queued while the request is in flight must not
|
|
// be swept into the "delivered" set below.
|
|
const snapshot = eventQueue.slice();
|
|
const payload = buildPayload(snapshot);
|
|
if (payload == null) return;
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
|
|
|
|
try {
|
|
await fetch(`${POSTHOG_HOST}/batch/`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Connection: "close" },
|
|
body: payload,
|
|
signal: controller.signal,
|
|
});
|
|
// Delivered — forget exactly what was sent (events queued while the
|
|
// request was in flight stay for the next flush).
|
|
const sent = new Set(snapshot);
|
|
eventQueue = eventQueue.filter((e) => !sent.has(e));
|
|
} catch {
|
|
// Silently ignore — telemetry must never break the CLI. The events stay
|
|
// queued so the exit-time flushSync() fallback can still deliver them.
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fire-and-forget flush for use in the `exit` event handler.
|
|
* Spawns a detached child process that sends the HTTP request independently,
|
|
* so the parent process exits immediately without waiting.
|
|
*/
|
|
export function flushSync(): void {
|
|
const payload = buildPayload(eventQueue);
|
|
if (payload == null) return;
|
|
eventQueue = [];
|
|
|
|
try {
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
"-e",
|
|
`fetch(${JSON.stringify(`${POSTHOG_HOST}/batch/`)},{method:"POST",headers:{"Content-Type":"application/json"},body:${JSON.stringify(payload)},signal:AbortSignal.timeout(${FLUSH_TIMEOUT_MS})}).catch(()=>{})`,
|
|
],
|
|
{ detached: true, stdio: "ignore" },
|
|
);
|
|
// Let the parent exit without waiting for the child
|
|
child.unref();
|
|
} catch {
|
|
// Silently ignore
|
|
}
|
|
}
|