mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 20:07:39 +00:00
refactor(cli): extract telemetry delivery into transport.ts (#2344)
## What Extracts the reliability-critical telemetry **delivery layer** — the in-memory event queue, async `flush()`, and the exit-time detached-child `flushSync()` — out of `packages/cli/src/telemetry/client.ts` into a new `transport.ts`. `client.ts` stays the CLI-facing **policy** layer: - `shouldTrack()` opt-out checks (dev mode, `DO_NOT_TRACK`, `HYPERFRAMES_NO_TELEMETRY`, config) - `trackEvent()` system-metadata enrichment - `showTelemetryNotice()` first-run disclosure …and re-exports `flush` / `flushSync`, so `events.ts`, `index.ts`, and the `cli.ts` exit handlers keep importing from `./client.js` **unchanged**. ## Why This is the code path that had the process-exit data-loss bug fixed in #2105 — render telemetry was ~6× undercounted and geographically US-skewed because the old drain-first flush emptied the queue before delivery confirmed, and the render command's `process.exit()` teardown killed the in-flight request. Isolating the delivery mechanism into its own focused, dependency-light module (only `./config` + node builtins) keeps that subtle, reliability-critical path in one place and reduces `client.ts` to just policy. Follow-up to the render-telemetry-gap investigation. A delivery-health canary was also added to the [CLI Observability dashboard](https://us.posthog.com/project/356858/dashboard/1634055) — `render_complete ÷ successful render commands`, which should sit ~1.0 and would surface any regression of this class immediately. ## How Pure code motion — **no behavior change, public API identical**. `transport.ts` owns the queue and stamps each event's dedup `uuid` + ISO timestamp in a new `enqueue()`; `trackEvent()` enriches with system metadata then calls `enqueue()`. `buildPayload`/`flush`/`flushSync` bodies are moved verbatim. ## Test plan - [x] `vitest run src/telemetry/client.test.ts src/telemetry/events.test.ts` → **38/38 pass** (client.test.ts still validates queue-retention, uuid idempotency, and the detached-child flushSync path through the public API — unchanged) - [x] `oxlint` clean, `oxfmt --check` clean - [x] `tsc --noEmit` — no new type errors in `telemetry/` - [x] `bun run build` succeeds 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -1,43 +1,21 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
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";
|
||||
|
||||
// This is a public project API key — safe to embed in client-side code.
|
||||
// It only allows writing events, not reading data.
|
||||
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
const POSTHOG_HOST = "https://us.i.posthog.com";
|
||||
const FLUSH_TIMEOUT_MS = 5_000;
|
||||
import { enqueue, POSTHOG_API_KEY, type EventProperties } from "./transport.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightweight PostHog client — uses the HTTP batch API directly to avoid
|
||||
// pulling in the full posthog-node SDK and its dependencies.
|
||||
// All calls are fire-and-forget with a hard timeout.
|
||||
// CLI-facing telemetry policy: opt-out checks, system-metadata enrichment, and
|
||||
// the first-run disclosure notice. The reliability-critical delivery layer
|
||||
// (the event queue, `flush()`, and the exit-time `flushSync()`) lives in
|
||||
// transport.ts. `flush` / `flushSync` are re-exported here so existing callers
|
||||
// (events.ts, index.ts, the cli.ts exit handlers) keep importing from
|
||||
// `./client.js` unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EventProperties {
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
export { flush, flushSync } from "./transport.js";
|
||||
|
||||
let telemetryEnabled: boolean | null = null;
|
||||
|
||||
@@ -71,6 +49,8 @@ export function shouldTrack(): boolean {
|
||||
|
||||
/**
|
||||
* Queue a telemetry event. Non-blocking, fail-silent.
|
||||
* Enriches the event with system metadata, then hands it to the transport
|
||||
* queue (which stamps the dedup uuid + timestamp).
|
||||
*/
|
||||
export function trackEvent(
|
||||
event: string,
|
||||
@@ -80,11 +60,9 @@ export function trackEvent(
|
||||
if (!shouldTrack()) return;
|
||||
|
||||
const sys = getSystemMeta();
|
||||
eventQueue.push({
|
||||
uuid: randomUUID(),
|
||||
enqueue(
|
||||
event,
|
||||
distinctId,
|
||||
properties: {
|
||||
{
|
||||
...properties,
|
||||
cli_version: VERSION,
|
||||
os: process.platform,
|
||||
@@ -107,101 +85,8 @@ export function trackEvent(
|
||||
term_program: sys.term_program ?? undefined,
|
||||
agent_env_hints: sys.agent_env_hints ?? undefined,
|
||||
},
|
||||
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
|
||||
}
|
||||
distinctId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
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.
|
||||
export const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EventProperties {
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user