mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* Add canonical PostHog core metrics events Co-authored-by: multica-agent <github@multica.ai> * Address analytics review feedback Co-authored-by: multica-agent <github@multica.ai> * Tighten analytics review follow-ups Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: multica-agent <github@multica.ai>
392 lines
14 KiB
TypeScript
392 lines
14 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";
|
|
|
|
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";
|
|
// Likewise pageviews: the initial "/" pageview is the anchor of the
|
|
// acquisition funnel, and the Next.js router fires it on mount before the
|
|
// config fetch resolves. We keep the first pending pageview so that step
|
|
// doesn't silently drop.
|
|
let pendingPageview: string | undefined | null = null;
|
|
// 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> }
|
|
| { kind: "set"; 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 {
|
|
captureDownloadIntent,
|
|
captureDownloadPageViewed,
|
|
captureDownloadInitiated,
|
|
type DownloadIntentSource,
|
|
type DownloadDetectPayload,
|
|
type DownloadInitiatedPayload,
|
|
} from "./download";
|
|
|
|
export {
|
|
captureFeedbackOpened,
|
|
type FeedbackOpenedSource,
|
|
} from "./feedback";
|
|
|
|
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,
|
|
capture_exceptions: false,
|
|
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;
|
|
}
|
|
// And any first pageview we captured while config was loading.
|
|
if (pendingPageview !== null) {
|
|
posthog.capture("$pageview", pendingPageview ? { $current_url: pendingPageview } : undefined);
|
|
pendingPageview = 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") {
|
|
posthog.capture(op.name, 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;
|
|
pendingPageview = 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 function captureEvent(
|
|
name: string,
|
|
props?: Record<string, unknown>,
|
|
): void {
|
|
if (!initialized) {
|
|
pendingOps.push({ kind: "event", name, props });
|
|
return;
|
|
}
|
|
posthog.capture(name, 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";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Capture a page view. Call once per client-side navigation. We disable
|
|
* posthog's automatic pageview tracking in init() so this module owns the
|
|
* event shape — that makes it trivial to add properties (e.g. workspace
|
|
* slug) without fighting the SDK.
|
|
*
|
|
* Calls before initAnalytics() buffer the most-recent path so the first
|
|
* pageview isn't dropped on slow /api/config fetches. Subsequent pre-init
|
|
* pageviews overwrite the buffer; after init flushes, every navigation
|
|
* captures synchronously as expected.
|
|
*/
|
|
export function capturePageview(path?: string): void {
|
|
if (!initialized) {
|
|
pendingPageview = path ?? "";
|
|
return;
|
|
}
|
|
posthog.capture("$pageview", path ? { $current_url: path } : undefined);
|
|
}
|
|
|
|
/**
|
|
* On the very first anonymous pageview 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 "";
|
|
}
|