mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 01:19:42 +02:00
* feat(diagnostics): capture the JS stack of a hung renderer (MUL-5345) Route attribution shipped in 0.4.12 and did its job: hard hangs are no longer scattered, they cluster on one page (10 of 12 hangs, 8 distinct users, spread over 16 hours). It also hit its ceiling there. That page hosts three modes on a single route and the mode lives in component state, so the route field cannot say which of them froze — and a page name was never going to name the function either. Two rounds of code-reading produced two hypotheses and both were wrong, and one manual repro attempt covered a path the telemetry never pointed at. Naming the code requires reading it off the stuck thread. When the renderer hangs, the main process attaches the DevTools protocol and asks for the stack. The channel is warmed while the renderer is healthy because a command sent after the thread is stuck is never dispatched — measured on the pinned Electron 39.8.7, where a post-hang attach returned nothing in 5s while a pause on a warm channel returned the stack in 2ms with the blocking function on top. Holding the channel open all session showed no cost beyond run-to-run noise (A/B/A; the ordering drift between cold phases exceeded the effect). That channel is the reason this ships behind a fail-closed server flag rather than on by default. `desktop_hang_stack_capture` rides the existing `/api/config` feature flags; main starts off, only an explicit `true` enables it, and revoking it detaches the channels rather than merely skipping the next capture. Main cannot read config itself, so the renderer forwards the one bit — which means a config that never arrives also lands on off. Privacy is unchanged in kind from `$exception`: four scalar fields per frame, `scopeChain` and `this` dropped so no handle can be dereferenced into user data, script URLs reduced to their bundle-relative tail, and a four-verb CDP allowlist that a source-level test pins to a single callsite. Resume is unconditional — a capture must never turn a recoverable hang into a permanent one. Delivery is fixed alongside, because a stack that cannot be sent is not worth capturing: `freeze:get-last` no longer deletes on read, the report goes out with `send_instantly`, and the breadcrumb is retired only after a grace window, so a second hang inside that window leaves the file for the next boot instead of taking the report with it (the MUL-4115 failure mode). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(diagnostics): close the kill switch, egress and multi-window gaps (MUL-5345) Three review findings on #6026, all in failure paths the tests didn't reach. The kill switch didn't reliably revoke. `coolDebuggerChannel` only detached after `Debugger.disable` resolved, so a failed disable left the channel attached — the exact state the switch exists to exit. Disable is a courtesy to the renderer; detach is the contract, so it now runs in `finally`. Warming had the mirror bug: an attach we made and could not enable returned false while leaving the channel open, stranding a debugger on a renderer nothing tracks. That attach is rolled back now, and only that one — a channel someone else owns (DevTools) is left alone. Stack frames were sanitized at capture and then forwarded verbatim at egress. Between those two points they cross an on-disk breadcrumb that `readFreezeBreadcrumb` barely validates, by design: it only has to survive version skew. So "sanitized once" was not a property the flush side could rely on — an older build, a corrupt file or a future writer could put a `scopeChain` handle or an absolute install path in there and it would ship. Both ends now rebuild frames through one shared whitelist, which also makes them impossible to drift apart. The url reduction is idempotent so re-running it costs nothing. The control flag was global, and that does not survive multiple windows. Every renderer publishes `false` before its own config lands, so a window opened while capture was on either never warmed (the global value never changed, so nothing warmed the new webContents) or cooled every other window on its way up. State is per renderer now; they converge on the same value because they read the same config, but each on its own schedule. Regression tests for each: detach after a throwing disable and rollback after a throwing enable, a frame carrying `scopeChain` / `this` / an absolute path reaching the flush side, and a second window warming while the first is already on without revoking it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
// Frontend analytics glue. Thin wrapper over posthog-js.
|
|
//
|
|
// The source-of-truth event catalog is `docs/analytics.md`. This module only
|
|
// handles the two things the backend can't do itself: attribution capture on
|
|
// first anonymous pageview, and person-identity merge on login. Every funnel
|
|
// event (signup, workspace_created, runtime_registered, issue_executed,
|
|
// invite_sent, invite_accepted) is emitted server-side — see
|
|
// `server/internal/analytics`.
|
|
//
|
|
// Configuration comes from the backend's `/api/config` response (populated
|
|
// from POSTHOG_API_KEY on the server), NOT from NEXT_PUBLIC_* envs. That
|
|
// keeps self-hosted Docker images from leaking our project key — their
|
|
// backend returns an empty key and this module stays inert.
|
|
|
|
import posthog from "posthog-js";
|
|
import { redactExceptionProperties } from "./redact-exception";
|
|
import { shouldDropException } from "./exception-dedupe";
|
|
import { isBenignException } from "./benign-exceptions";
|
|
|
|
export const EVENT_SCHEMA_VERSION = 2;
|
|
|
|
const SIGNUP_SOURCE_COOKIE = "multica_signup_source";
|
|
// Per-value cap keeps a long utm_content from blowing the budget. We drop
|
|
// the entire cookie if the JSON still exceeds the overall limit — partial
|
|
// JSON is worse than no attribution because PostHog can't parse it.
|
|
const SIGNUP_SOURCE_VALUE_MAX_LEN = 96;
|
|
const SIGNUP_SOURCE_MAX_LEN = 512;
|
|
const UTM_KEYS = [
|
|
"utm_source",
|
|
"utm_medium",
|
|
"utm_campaign",
|
|
"utm_content",
|
|
"utm_term",
|
|
] as const;
|
|
|
|
let initialized = false;
|
|
// auth-initializer fetches /api/config and /api/me in parallel — on a
|
|
// slow-config path, identify() can fire before initAnalytics(). Buffer the
|
|
// most recent pending identify (only one matters, since it's per-session)
|
|
// and flush it inside initAnalytics.
|
|
let pendingIdentify: { userId: string; props?: Record<string, unknown> } | null = null;
|
|
let currentUserId: string | null = null;
|
|
let analyticsEnvironment = "dev";
|
|
// Frontend-emitted events (captureEvent) and person-property updates
|
|
// (setPersonProperties) can also arrive before init — same config-race as
|
|
// identify/pageview. We replay them in order once init succeeds. These
|
|
// only ever carry user-triggered signals on identified users, so the
|
|
// buffer stays small (~one step-transition worth).
|
|
type PendingOp =
|
|
| {
|
|
kind: "event";
|
|
name: string;
|
|
props?: Record<string, unknown>;
|
|
options?: CaptureEventOptions;
|
|
}
|
|
| { kind: "set"; props: Record<string, unknown> }
|
|
| { kind: "exception"; error: unknown; props?: Record<string, unknown> };
|
|
const pendingOps: PendingOp[] = [];
|
|
// Cached super-properties so resetAnalytics() can re-register them after
|
|
// posthog.reset() wipes the persisted set. Without this, logout / account
|
|
// switch silently drops client_type + app_version from every subsequent
|
|
// event until a full reload.
|
|
let superProperties: Record<string, unknown> = {};
|
|
|
|
export interface AnalyticsConfig {
|
|
key: string;
|
|
host: string;
|
|
/**
|
|
* Client app version — attached to every event as an `app_version`
|
|
* super-property. Web injects the build-time tag / sha; desktop reads from
|
|
* the Electron API. Optional because local dev may not have a version
|
|
* available.
|
|
*/
|
|
appVersion?: string;
|
|
environment?: string;
|
|
}
|
|
|
|
export type ClientType = "desktop" | "web";
|
|
|
|
/**
|
|
* Classify the current runtime as desktop (Electron renderer) or web. Used as
|
|
* a super-property so every event can be split by client without relying on
|
|
* PostHog's `$lib`, which reports "web" in both the Next.js app and the
|
|
* Electron renderer (both Chromium).
|
|
*
|
|
* Signals we trust:
|
|
* - `window.electron` is exposed by the preload script in every renderer.
|
|
* - `navigator.userAgent` contains "Electron" as a fallback.
|
|
*/
|
|
export function detectClientType(): ClientType {
|
|
if (typeof window === "undefined") return "web";
|
|
const w = window as unknown as { electron?: unknown; desktopAPI?: unknown };
|
|
if (w.electron || w.desktopAPI) return "desktop";
|
|
if (typeof navigator !== "undefined" && /Electron/i.test(navigator.userAgent)) {
|
|
return "desktop";
|
|
}
|
|
return "web";
|
|
}
|
|
|
|
/**
|
|
* Initialize posthog-js if a key is present. Safe to call multiple times;
|
|
* subsequent calls with the same config are no-ops.
|
|
*
|
|
* Returns `true` when analytics is actually running; `false` when disabled
|
|
* (no key, SSR, or already initialized with a conflicting key — which we
|
|
* treat as "use the existing instance").
|
|
*/
|
|
export function initAnalytics(config: AnalyticsConfig | null | undefined): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
if (!config?.key) return false;
|
|
if (initialized) return true;
|
|
|
|
posthog.init(config.key, {
|
|
api_host: config.host || "https://us.i.posthog.com",
|
|
// person_profiles=identified_only keeps anonymous drive-by traffic off
|
|
// the billed events until they actually identify, which aligns with how
|
|
// our funnel is set up: signup is the first real funnel step.
|
|
person_profiles: "identified_only",
|
|
// Turn off every on-by-default auto-capture surface. Our funnel is
|
|
// narrow and explicit (the events in docs/analytics.md + a manual
|
|
// $pageview). Autocapture floods the Activity view with anonymous
|
|
// "clicked button" / "clicked link" noise, burns the billed event
|
|
// budget, and risks capturing user-typed content in input values.
|
|
// Turn things back on deliberately if we ever want them.
|
|
capture_pageview: false,
|
|
autocapture: false,
|
|
capture_heatmaps: false,
|
|
capture_dead_clicks: false,
|
|
// Exception autocapture IS on: posthog-js attaches window.onerror +
|
|
// unhandledrejection handlers and sends `$exception` events with the
|
|
// error's stack. Unlike the click/heatmap autocapture above, this is
|
|
// explicit failure signal (not behavioral noise) and is the one PostHog
|
|
// surface that natively handles thrown JS errors — see the failure-tier
|
|
// split in packages/core/diagnostics. (Production builds are minified;
|
|
// upload source maps to PostHog to de-minify the stacks.)
|
|
//
|
|
// Error messages can interpolate user input (a validation error with the
|
|
// typed value, a URL with a token), so `before_send` scrubs the message
|
|
// and `$exception_list[].value` before the event leaves the client. Stack
|
|
// frames (code locations) are kept. See redact-exception.ts.
|
|
//
|
|
// After scrubbing, a session-level fuse drops repeats of the same error so
|
|
// a render loop or a polling fetch that keeps throwing can't emit 100+
|
|
// identical `$exception` events per session (MUL-3331). The fingerprint is
|
|
// built only from the already-redacted fields, so no PII reaches storage.
|
|
// Order matters: redact first, then fingerprint the redacted shape.
|
|
capture_exceptions: true,
|
|
before_send: (event) => {
|
|
if (event && event.event === "$exception") {
|
|
// Drop known-benign browser noise (e.g. ResizeObserver loop) entirely
|
|
// — checked on the raw message before redaction. These dominate the
|
|
// stream and carry no signal, so they skip both redaction and the
|
|
// dedupe fuse. See benign-exceptions.ts.
|
|
if (isBenignException(event.properties)) return null;
|
|
redactExceptionProperties(event.properties);
|
|
if (shouldDropException(event.properties)) return null;
|
|
}
|
|
return event;
|
|
},
|
|
disable_session_recording: true,
|
|
disable_surveys: true,
|
|
});
|
|
analyticsEnvironment = normalizeEnvironment(config.environment);
|
|
// Register super-properties — attached to every event emitted from this
|
|
// client. `client_type` is the canonical split between desktop and web
|
|
// (PostHog's own `$lib` reports "web" for both because Electron renderers
|
|
// are Chromium). `app_version` is optional so self-hosted or local dev
|
|
// builds without a version don't pollute the property.
|
|
// We cache the set so resetAnalytics() can re-apply it after
|
|
// posthog.reset() — reset() clears persisted super-properties otherwise.
|
|
superProperties = {
|
|
client_type: detectClientType(),
|
|
event_schema_version: EVENT_SCHEMA_VERSION,
|
|
environment: analyticsEnvironment,
|
|
is_demo: false,
|
|
};
|
|
if (config.appVersion) superProperties.app_version = config.appVersion;
|
|
posthog.register(superProperties);
|
|
initialized = true;
|
|
|
|
// Flush any identify() that arrived before init resolved.
|
|
if (pendingIdentify) {
|
|
currentUserId = pendingIdentify.userId;
|
|
posthog.identify(pendingIdentify.userId, pendingIdentify.props);
|
|
pendingIdentify = null;
|
|
}
|
|
// Replay buffered events / person-property updates in their original
|
|
// order — funnel correctness depends on sequence (e.g. a user submits
|
|
// the questionnaire and then finishes onboarding within the same
|
|
// config-race window).
|
|
while (pendingOps.length > 0) {
|
|
const op = pendingOps.shift()!;
|
|
if (op.kind === "event") {
|
|
captureNow(op.name, op.props, op.options);
|
|
} else if (op.kind === "exception") {
|
|
posthog.captureException(op.error, withClientEventProperties(op.props));
|
|
} else {
|
|
capturePersonSet(op.props);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Merge the current anonymous session into the logged-in person. Must be
|
|
* called exactly once per auth transition (login / session-resume). Pulling
|
|
* attribution properties into person_properties on identify is how we keep
|
|
* UTM / referrer on the user profile without re-emitting them per event.
|
|
*
|
|
* Calls before initAnalytics() are buffered — auth-initializer fetches
|
|
* config and user in parallel, so identify can arrive first.
|
|
*/
|
|
export function identify(userId: string, userProperties?: Record<string, unknown>): void {
|
|
currentUserId = userId;
|
|
if (!initialized) {
|
|
pendingIdentify = { userId, props: userProperties };
|
|
return;
|
|
}
|
|
posthog.identify(userId, userProperties);
|
|
}
|
|
|
|
/**
|
|
* Clear the client-side identity on logout so the next login merges cleanly
|
|
* and doesn't bleed the previous user's events into a new session.
|
|
*/
|
|
export function resetAnalytics(): void {
|
|
currentUserId = null;
|
|
pendingIdentify = null;
|
|
pendingOps.length = 0;
|
|
if (!initialized) return;
|
|
posthog.reset();
|
|
// reset() wipes persisted super-properties too, so re-register the ones
|
|
// set at init time. Otherwise every event after logout / account-switch
|
|
// would be missing client_type + app_version until a full reload.
|
|
if (Object.keys(superProperties).length > 0) {
|
|
posthog.register(superProperties);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Capture a frontend-emitted event. Most funnel events fire server-side
|
|
* (see `server/internal/analytics`); this wrapper is reserved for the
|
|
* handful of signals the backend can't see — primarily the Step 3
|
|
* platform-fork choice on web, where the user's click never round-trips
|
|
* to a handler.
|
|
*
|
|
* Calls before initAnalytics() buffer in order so a late-arriving config
|
|
* doesn't silently swallow a step transition.
|
|
*/
|
|
export interface CaptureEventOptions {
|
|
/**
|
|
* Bypass posthog-js's batching timer and put the event on the wire now.
|
|
* Batching is a JS timer in the same thread that is about to freeze or be
|
|
* killed, so a failure report queued normally can be lost exactly when it
|
|
* matters (MUL-4115: three deterministic hangs, zero events delivered).
|
|
* Reserve this for failure telemetry — routine events should batch.
|
|
*/
|
|
sendInstantly?: boolean;
|
|
/**
|
|
* Fired once the event has been handed to posthog.capture.
|
|
*
|
|
* This is HAND-OFF, not delivery: the request may still be in flight, and
|
|
* posthog-js exposes no delivery callback to wait on. Never treat it as
|
|
* permission to discard the only copy of something — a caller that deletes
|
|
* persisted state here loses it whenever the app dies between hand-off and
|
|
* the wire (see freeze-flush.ts, which waits out a grace window instead).
|
|
*
|
|
* It also never fires on a build with analytics disabled, so anything
|
|
* waiting on it needs an expiry path of its own.
|
|
*/
|
|
onCaptured?: () => void;
|
|
}
|
|
|
|
export function captureEvent(
|
|
name: string,
|
|
props?: Record<string, unknown>,
|
|
options?: CaptureEventOptions,
|
|
): void {
|
|
if (!initialized) {
|
|
pendingOps.push({ kind: "event", name, props, options });
|
|
return;
|
|
}
|
|
captureNow(name, props, options);
|
|
}
|
|
|
|
function captureNow(
|
|
name: string,
|
|
props?: Record<string, unknown>,
|
|
options?: CaptureEventOptions,
|
|
): void {
|
|
posthog.capture(
|
|
name,
|
|
withClientEventProperties(props),
|
|
options?.sendInstantly ? { send_instantly: true } : undefined,
|
|
);
|
|
options?.onCaptured?.();
|
|
}
|
|
|
|
/**
|
|
* Report a caught exception that never reached `window.onerror` — a React
|
|
* render-phase error swallowed by an error boundary. Global uncaught errors
|
|
* and unhandled rejections are already captured automatically by posthog-js
|
|
* (`capture_exceptions: true`); this wrapper is for the boundary case those
|
|
* handlers can't see.
|
|
*
|
|
* Currently called by the web route-level `global-error`. Section-level
|
|
* `@multica/ui` ErrorBoundary can opt in by passing `onError={captureException}`
|
|
* at its call sites; it is not wired app-wide (those failures already degrade
|
|
* gracefully with fallback UI).
|
|
*
|
|
* Calls before initAnalytics() buffer in order, same as captureEvent.
|
|
*/
|
|
export function captureException(
|
|
error: unknown,
|
|
props?: Record<string, unknown>,
|
|
): void {
|
|
if (!initialized) {
|
|
pendingOps.push({ kind: "exception", error, props });
|
|
return;
|
|
}
|
|
posthog.captureException(error, withClientEventProperties(props));
|
|
}
|
|
|
|
/**
|
|
* Set (overwrite) person properties on the currently identified user.
|
|
* Mirrors the backend's `Event.Set` path — keep these aligned so the
|
|
* same cohort signals (role, use_case, platform_preference) are
|
|
* queryable regardless of which side emitted last. Use for mutable
|
|
* signals; use `identify(userId, { $set_once: {...} })` style for
|
|
* attribution fields that must never be overwritten.
|
|
*/
|
|
export function setPersonProperties(props: Record<string, unknown>): void {
|
|
if (!initialized) {
|
|
pendingOps.push({ kind: "set", props });
|
|
return;
|
|
}
|
|
capturePersonSet(props);
|
|
}
|
|
|
|
// The public wire-level contract for `$set` is a no-op event carrying a
|
|
// `$set` property. Wrapping it here (rather than calling
|
|
// `posthog.setPersonProperties` directly) keeps us version-independent —
|
|
// older posthog-js builds expose the same protocol under `posthog.people.set`,
|
|
// and the capture form works uniformly.
|
|
function capturePersonSet(props: Record<string, unknown>): void {
|
|
posthog.capture("$set", { $set: props });
|
|
}
|
|
|
|
function withClientEventProperties(
|
|
props?: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const next: Record<string, unknown> = { ...(props ?? {}) };
|
|
if (currentUserId && next.user_id === undefined) {
|
|
next.user_id = currentUserId;
|
|
}
|
|
if (next.event_schema_version === undefined) {
|
|
next.event_schema_version = EVENT_SCHEMA_VERSION;
|
|
}
|
|
if (next.environment === undefined) {
|
|
next.environment = analyticsEnvironment;
|
|
}
|
|
if (next.is_demo === undefined) {
|
|
next.is_demo = false;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function normalizeEnvironment(value: string | undefined): string {
|
|
switch ((value || "").trim().toLowerCase()) {
|
|
case "production":
|
|
case "prod":
|
|
return "production";
|
|
case "staging":
|
|
case "stage":
|
|
return "staging";
|
|
case "development":
|
|
case "dev":
|
|
case "test":
|
|
case "local":
|
|
return "dev";
|
|
default:
|
|
return "dev";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* On the very first anonymous page load in a browser session, read UTM +
|
|
* referrer and stash them in a cookie that the backend reads during signup.
|
|
*
|
|
* Never use raw `document.referrer` as attribution — it can leak OAuth
|
|
* callback URLs with `code` / `state` in the query string. We keep only the
|
|
* referrer's origin (scheme + host), which is what a funnel actually needs.
|
|
*
|
|
* This cookie is what `signup_source` in the backend's signup event reads
|
|
* from; both fields are intentionally opaque JSON so the schema can evolve
|
|
* without a backend deploy.
|
|
*/
|
|
export function captureSignupSource(): void {
|
|
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
if (readCookie(SIGNUP_SOURCE_COOKIE)) return;
|
|
|
|
const source: Record<string, string> = {};
|
|
const cap = (v: string) =>
|
|
v.length > SIGNUP_SOURCE_VALUE_MAX_LEN ? v.slice(0, SIGNUP_SOURCE_VALUE_MAX_LEN) : v;
|
|
|
|
try {
|
|
const params = new URLSearchParams(window.location.search);
|
|
for (const key of UTM_KEYS) {
|
|
const v = params.get(key);
|
|
if (v) source[key] = cap(v);
|
|
}
|
|
} catch {
|
|
// URL APIs unavailable — skip silently.
|
|
}
|
|
|
|
const refOrigin = safeReferrerOrigin(document.referrer);
|
|
if (refOrigin) source.referrer_origin = cap(refOrigin);
|
|
|
|
if (Object.keys(source).length === 0) return;
|
|
|
|
const payload = JSON.stringify(source);
|
|
// Drop rather than mid-JSON truncate — a half-string would fail to parse
|
|
// on the backend and the attribution would be worse than missing.
|
|
if (payload.length > SIGNUP_SOURCE_MAX_LEN) return;
|
|
|
|
// 30-day expiry covers the typical signup consideration window. Lax is
|
|
// the right default — the cookie is only consumed by same-origin auth.
|
|
const maxAge = 60 * 60 * 24 * 30;
|
|
document.cookie = `${SIGNUP_SOURCE_COOKIE}=${encodeURIComponent(payload)}; path=/; max-age=${maxAge}; samesite=lax`;
|
|
}
|
|
|
|
function safeReferrerOrigin(referrer: string): string {
|
|
if (!referrer) return "";
|
|
try {
|
|
const url = new URL(referrer);
|
|
if (url.origin === window.location.origin) return "";
|
|
return url.origin;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function readCookie(name: string): string {
|
|
if (typeof document === "undefined") return "";
|
|
const prefix = `${name}=`;
|
|
const parts = document.cookie ? document.cookie.split("; ") : [];
|
|
for (const part of parts) {
|
|
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
|
|
}
|
|
return "";
|
|
}
|