mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 06:39:01 +02:00
* chore(analytics): retire redundant PostHog tracking (MUL-4127) PostHog had become a chaotic, largely-unused second copy of data we already query from the DB and Grafana. Remove the redundant instrumentation. Server: every product event (signup, workspace_created, issue_created, issue_executed, chat_message_sent, team_invite_*, onboarding_*, agent_created, cloud_waitlist_joined, feedback_submitted, contact_sales_submitted, squad_created, autopilot_created) is now in metricsOnlyEvents, so metrics.RecordEvent still increments the Prometheus/Grafana counter but no longer ships to PostHog. DB rows remain the source of truth. Runtime/autopilot/ agent_task lifecycle were already Prometheus-only. Frontend: delete the PostHog-only funnel instrumentation — $pageview (+ web and desktop trackers), download_intent_expressed/page_viewed/initiated, the onboarding_started mirror, onboarding_runtime_path_selected/detected, feedback_opened, and source_backfill_*. The source-backfill modal itself stays (it PATCHes the questionnaire to the DB). Kept on PostHog (frontend only): $exception autocapture and the client_crash / client_unresponsive stability telemetry (no DB equivalent), plus $identify/$set. captureSignupSource (attribution cookie) stays — it still feeds the signup_source Prometheus label. Verified: pnpm typecheck, pnpm lint (0 errors), vitest (core/views/web/desktop), go test ./internal/analytics/... ./internal/metrics/... Co-authored-by: multica-agent <github@multica.ai> * docs(analytics): fix stale PostHog references after MUL-4127 (review follow-up) Addresses review of #4996 — three spots still described server events as active PostHog signals after they became metrics-only: - docs/analytics.md: issue_executed is no longer a PostHog success signal; it is Prometheus-only (multica_issue_executed_total) + issue.first_executed_at, in both the event contract and the Reconciliation section. - docs/analytics.md: the signup $set_once person properties (email, signup_source) are no longer emitted — signup is Prometheus-only; only the bucketed signup_source survives as the multica_signup_total label. - server/internal/metrics/business_events.go: RecordEvent doc comment no longer claims it ships product events to PostHog / "PostHog is reserved for user/product-behaviour events" — every server event is now metrics-only. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
409 lines
16 KiB
TypeScript
409 lines
16 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> }
|
|
| { 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") {
|
|
posthog.capture(op.name, withClientEventProperties(op.props));
|
|
} 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 function captureEvent(
|
|
name: string,
|
|
props?: Record<string, unknown>,
|
|
): void {
|
|
if (!initialized) {
|
|
pendingOps.push({ kind: "event", name, props });
|
|
return;
|
|
}
|
|
posthog.capture(name, withClientEventProperties(props));
|
|
}
|
|
|
|
/**
|
|
* 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 "";
|
|
}
|