mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-17 15:19:00 +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>
210 lines
6.8 KiB
TypeScript
210 lines
6.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Mock posthog-js before importing the module under test so the module's
|
|
// top-level `import posthog from "posthog-js"` resolves to the mock.
|
|
vi.mock("posthog-js", () => {
|
|
const mock = {
|
|
init: vi.fn(),
|
|
register: vi.fn(),
|
|
reset: vi.fn(),
|
|
identify: vi.fn(),
|
|
capture: vi.fn(),
|
|
captureException: vi.fn(),
|
|
};
|
|
return { default: mock };
|
|
});
|
|
|
|
// Re-import per test so module-level `initialized` / cached super-props
|
|
// don't leak between cases.
|
|
async function loadModule() {
|
|
vi.resetModules();
|
|
const analytics = await import("./index");
|
|
const posthog = (await import("posthog-js")).default as unknown as {
|
|
init: ReturnType<typeof vi.fn>;
|
|
register: ReturnType<typeof vi.fn>;
|
|
reset: ReturnType<typeof vi.fn>;
|
|
captureException: ReturnType<typeof vi.fn>;
|
|
};
|
|
posthog.init.mockClear();
|
|
posthog.register.mockClear();
|
|
posthog.reset.mockClear();
|
|
posthog.captureException.mockClear();
|
|
return { analytics, posthog };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("window", {});
|
|
vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0" });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("initAnalytics super-properties", () => {
|
|
it("registers client_type and app_version after posthog.init", async () => {
|
|
const { analytics, posthog } = await loadModule();
|
|
analytics.initAnalytics({ key: "k", host: "", appVersion: "1.2.3" });
|
|
expect(posthog.register).toHaveBeenCalledWith({
|
|
client_type: "web",
|
|
app_version: "1.2.3",
|
|
environment: "dev",
|
|
event_schema_version: 2,
|
|
is_demo: false,
|
|
});
|
|
});
|
|
|
|
it("omits app_version when not provided", async () => {
|
|
const { analytics, posthog } = await loadModule();
|
|
analytics.initAnalytics({ key: "k", host: "" });
|
|
expect(posthog.register).toHaveBeenCalledWith({
|
|
client_type: "web",
|
|
environment: "dev",
|
|
event_schema_version: 2,
|
|
is_demo: false,
|
|
});
|
|
});
|
|
|
|
it("detects desktop when window.electron is present", async () => {
|
|
vi.stubGlobal("window", { electron: {} });
|
|
const { analytics, posthog } = await loadModule();
|
|
analytics.initAnalytics({ key: "k", host: "" });
|
|
expect(posthog.register).toHaveBeenCalledWith({
|
|
client_type: "desktop",
|
|
environment: "dev",
|
|
event_schema_version: 2,
|
|
is_demo: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resetAnalytics", () => {
|
|
it("re-registers super-properties after reset so subsequent events keep client_type", async () => {
|
|
const { analytics, posthog } = await loadModule();
|
|
analytics.initAnalytics({ key: "k", host: "", appVersion: "1.2.3" });
|
|
posthog.register.mockClear();
|
|
|
|
analytics.resetAnalytics();
|
|
|
|
// reset() wipes persisted super-props; we re-register the cached set so
|
|
// the next session's events keep client_type + app_version.
|
|
expect(posthog.reset).toHaveBeenCalledTimes(1);
|
|
expect(posthog.register).toHaveBeenCalledWith({
|
|
client_type: "web",
|
|
app_version: "1.2.3",
|
|
environment: "dev",
|
|
event_schema_version: 2,
|
|
is_demo: false,
|
|
});
|
|
});
|
|
|
|
it("is a no-op when analytics was never initialized", async () => {
|
|
const { analytics, posthog } = await loadModule();
|
|
analytics.resetAnalytics();
|
|
expect(posthog.reset).not.toHaveBeenCalled();
|
|
expect(posthog.register).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("captureException", () => {
|
|
it("buffers a pre-init exception and flushes it on init", async () => {
|
|
const { analytics, posthog } = await loadModule();
|
|
const err = new Error("boom");
|
|
|
|
// Before init: buffered, nothing sent yet.
|
|
analytics.captureException(err, { source: "global-error" });
|
|
expect(posthog.captureException).not.toHaveBeenCalled();
|
|
|
|
// Init flushes the buffer in order.
|
|
analytics.initAnalytics({ key: "k", host: "" });
|
|
expect(posthog.captureException).toHaveBeenCalledTimes(1);
|
|
expect(posthog.captureException).toHaveBeenCalledWith(
|
|
err,
|
|
expect.objectContaining({ source: "global-error" }),
|
|
);
|
|
});
|
|
|
|
it("sends immediately once initialized", async () => {
|
|
const { analytics, posthog } = await loadModule();
|
|
analytics.initAnalytics({ key: "k", host: "" });
|
|
posthog.captureException.mockClear();
|
|
|
|
const err = new Error("later");
|
|
analytics.captureException(err);
|
|
expect(posthog.captureException).toHaveBeenCalledTimes(1);
|
|
expect(posthog.captureException).toHaveBeenCalledWith(err, expect.any(Object));
|
|
});
|
|
});
|
|
|
|
describe("before_send $exception pipeline", () => {
|
|
// before_send is registered inside posthog.init's config; pull it back out of
|
|
// the mock and drive it directly. Dedupe needs a working sessionStorage.
|
|
function makeMemoryStorage() {
|
|
const data = new Map<string, string>();
|
|
return {
|
|
getItem: (k: string) => (data.has(k) ? data.get(k)! : null),
|
|
setItem: (k: string, v: string) => void data.set(k, v),
|
|
removeItem: (k: string) => void data.delete(k),
|
|
clear: () => data.clear(),
|
|
key: (i: number) => Array.from(data.keys())[i] ?? null,
|
|
get length() {
|
|
return data.size;
|
|
},
|
|
};
|
|
}
|
|
|
|
type BeforeSend = (
|
|
e: { event: string; properties: Record<string, unknown> } | null,
|
|
) => unknown;
|
|
|
|
function getBeforeSend(posthog: { init: ReturnType<typeof vi.fn> }): BeforeSend {
|
|
const config = posthog.init.mock.calls[0]?.[1] as { before_send: BeforeSend };
|
|
return config.before_send;
|
|
}
|
|
|
|
function excEvent() {
|
|
return {
|
|
event: "$exception",
|
|
properties: {
|
|
$exception_list: [
|
|
{
|
|
type: "TypeError",
|
|
value: "Bad email bob@corp.com",
|
|
stacktrace: {
|
|
frames: [{ filename: "a.tsx", function: "f", lineno: 1, colno: 2 }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("sessionStorage", makeMemoryStorage());
|
|
});
|
|
|
|
it("redacts the message, then drops repeats past the per-fingerprint limit", async () => {
|
|
const { analytics, posthog } = await loadModule();
|
|
analytics.initAnalytics({ key: "k", host: "" });
|
|
const beforeSend = getBeforeSend(posthog);
|
|
|
|
const first = beforeSend(excEvent()) as { properties: { $exception_list: Array<{ value: string }> } };
|
|
// Redaction still runs before the fuse.
|
|
expect(first.properties.$exception_list[0]!.value).toBe("Bad email [redacted]");
|
|
|
|
expect(beforeSend(excEvent())).not.toBeNull();
|
|
expect(beforeSend(excEvent())).not.toBeNull();
|
|
// 4th identical exception is dropped.
|
|
expect(beforeSend(excEvent())).toBeNull();
|
|
});
|
|
|
|
it("passes non-$exception events through untouched", async () => {
|
|
const { analytics, posthog } = await loadModule();
|
|
analytics.initAnalytics({ key: "k", host: "" });
|
|
const beforeSend = getBeforeSend(posthog);
|
|
|
|
const evt = { event: "$pageview", properties: { $current_url: "/acme/issues" } };
|
|
expect(beforeSend(evt)).toBe(evt);
|
|
});
|
|
});
|