Files
hyperframes/packages/studio/src/utils/studioTelemetry.ts
T
Vance IngallsandClaude Opus 5 f81ab0162e fix(cli,studio): close the four R3 blocking gaps
P1 — SPA route bypassed the DNS-rebinding guard. Guarding only
/api/telemetry-identity left the catch-all as an open side door: a
rebound origin could fetch `/` and read __HF_CLI_DISTINCT_ID and
__HF_CLI_BUCKET_SEED straight out of the returned HTML. The SPA response
now applies the same isLoopbackHost() check; an untrusted Host still gets
a working Studio, just with no identity, seed or decisions injected.
Route-level regression added.

P1 — a CLI cohort roll could override Studio's own opt-out.
decideStudioCanary() adopted the injected decision before checking
isOptedOut(), so CLI-telemetry-on plus Studio-opted-out still enrolled
Studio. A bare boolean could not express the difference between a
deliberate override and an ordinary cohort roll, so the injected map now
carries provenance ({ enabled, forced }). Forced wins outright — it is
the documented escalation channel and must behave the same on both
surfaces — while a percentage roll now loses to this profile's opt-out.
Full interaction matrix tested.

P1 — the legacy studio:* path sat outside both contracts.
utils/studioTelemetry.ts shipped its own opt-out key and its own send
loop, so the documented hyperframes-studio:telemetryDisabled did not
silence it and its events carried no cohort assignment. It now honours
both keys (the legacy one stays, so nobody already opted out is quietly
re-enabled) and mixes in canaryEventProperties(), making "every
telemetry event carries the assignment" actually true.

P2 — partial salvage could drop a tripped breaker.
salvageInstallState() discarded the whole record when markerAt and
bucketSeed were both unusable, taking deParallelRouterTrialFired with it
and re-enrolling a machine whose router already failed. All three fields
are now independently salvageable.

Docs: canary-rollouts.mdx said "disabling telemetry disables the
reporting, not the enrolment" — exactly backwards since the opt-out gate
landed. Corrected; checked for other copies, none.

Tests: 13 new (4 opt-out precedence, 4 legacy-path opt-out and canary
props, 3 route-level host guard, 2 breaker salvage). Fault injection:
each of the four fixes reverted independently fails its own tests
(2 CLI + 1 Studio + 2 Studio).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:35:22 -07:00

142 lines
4.5 KiB
TypeScript

import { resolveStudioDistinctId } from "../telemetry/distinctId";
import { isOptedOut } from "../telemetry/config";
import { canaryEventProperties } from "../telemetry/canary";
// PostHog public ingest key — write-only, safe to ship in the client bundle
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
const POSTHOG_HOST = "https://us.i.posthog.com";
const FLUSH_INTERVAL_MS = 30_000;
const FLUSH_TIMEOUT_MS = 5_000;
interface EventProperties {
[key: string]: string | number | boolean | null | undefined;
}
interface QueuedEvent {
event: string;
properties: EventProperties;
timestamp: string;
}
let queue: QueuedEvent[] = [];
let flushTimer: ReturnType<typeof setInterval> | null = null;
// Delegates to the single source of truth (telemetry/distinctId.ts) so `studio:*`
// events share one id with `studio_*` / render events, and adopt the CLI's
// distinct_id when the CLI launched Studio.
function getDistinctId(): string {
return resolveStudioDistinctId();
}
/**
* Honours BOTH opt-out keys.
*
* This path predates telemetry/config.ts and shipped its own key, so the
* documented `hyperframes-studio:telemetryDisabled` was silently ignored here
* — `studio:*` events kept flowing for anyone who opted out the documented
* way. The legacy key stays honoured so nobody who already opted out gets
* quietly re-enabled by this fix.
*/
function isEnabled(): boolean {
if (isOptedOut()) return false;
try {
return localStorage.getItem("hf-studio-telemetry-opt-out") !== "1";
} catch {
return true;
}
}
function getSessionProperties(): EventProperties {
return {
studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
screen_width: window.screen?.width,
screen_height: window.screen?.height,
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
user_agent: navigator.userAgent,
// Route slug only — drop the query string, which carries the current
// selection (selId / selSelector are the user's own element ids/CSS
// selectors) and other view state we must not send to analytics.
url_hash: location.hash.replace(/#project\//, "").split("?")[0],
};
}
declare const __STUDIO_VERSION__: string;
export function trackStudioEvent(event: string, properties: EventProperties = {}): void {
if (!isEnabled()) return;
queue.push({
event: `studio:${event}`,
// Canary assignments on every event, matching the CLI and the newer
// studio client — "every telemetry event carries the assignment" has to
// include this path or a cohort breakdown silently omits `studio:*`.
properties: { ...getSessionProperties(), ...canaryEventProperties(), ...properties },
timestamp: new Date().toISOString(),
});
if (!flushTimer) {
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
}
}
async function flushEvents(): Promise<void> {
if (queue.length === 0) return;
const batch = queue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: getDistinctId(),
timestamp: e.timestamp,
}));
queue = [];
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" },
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
signal: controller.signal,
});
} catch {
// Telemetry must never break the studio
} finally {
clearTimeout(timeout);
}
}
// Synchronously drains the queue via sendBeacon — safe to call from any
// tab-hide handler regardless of listener registration order. Exported so
// other modules (e.g. sdkResolverShadow.ts) can force delivery of an event
// they just queued without racing this module's own visibilitychange
// listener below.
export function flushViaBeacon(): void {
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = null;
}
if (queue.length === 0) return;
const batch = queue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
distinct_id: getDistinctId(),
timestamp: e.timestamp,
}));
queue = [];
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
try {
navigator.sendBeacon(`${POSTHOG_HOST}/batch/`, body);
} catch {
// best-effort
}
}
if (typeof window !== "undefined") {
window.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushViaBeacon();
});
}