mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +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>
288 lines
9.0 KiB
TypeScript
288 lines
9.0 KiB
TypeScript
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { I18nProvider } from "@multica/core/i18n/react";
|
|
import enCommon from "../locales/en/common.json";
|
|
import enOnboarding from "../locales/en/onboarding.json";
|
|
|
|
const TEST_RESOURCES = { en: { common: enCommon, onboarding: enOnboarding } };
|
|
|
|
const { mockUser, mockSaveQuestionnaire } = vi.hoisted(() => ({
|
|
mockUser: { value: null as null | Record<string, unknown> },
|
|
mockSaveQuestionnaire: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@multica/core/auth", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/auth")>(
|
|
"@multica/core/auth",
|
|
);
|
|
const useAuthStore = Object.assign(
|
|
(selector: (s: { user: unknown }) => unknown) =>
|
|
selector({ user: mockUser.value }),
|
|
{ getState: () => ({ user: mockUser.value }) },
|
|
);
|
|
return { ...actual, useAuthStore };
|
|
});
|
|
|
|
vi.mock("@multica/core/onboarding", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/onboarding")>(
|
|
"@multica/core/onboarding",
|
|
);
|
|
return { ...actual, saveQuestionnaire: mockSaveQuestionnaire };
|
|
});
|
|
|
|
import { SourceBackfillModal } from "./source-backfill-modal";
|
|
|
|
function setUser(partial: Record<string, unknown> | null) {
|
|
mockUser.value = partial;
|
|
}
|
|
|
|
function wipeDismissCounters() {
|
|
for (let i = window.localStorage.length - 1; i >= 0; i--) {
|
|
const k = window.localStorage.key(i);
|
|
if (k && k.startsWith("multica.source_backfill.dismiss.")) {
|
|
window.localStorage.removeItem(k);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default tests run with reduced-motion *on* so the modal's entrance
|
|
* delay short-circuits and the dialog opens synchronously — keeps the
|
|
* behavioural tests focused on selection / submit / skip semantics
|
|
* rather than fighting timers. The dedicated entrance-delay test below
|
|
* overrides this to assert the deferred-open path.
|
|
*/
|
|
function mockPrefersReducedMotion(matches: boolean) {
|
|
Object.defineProperty(window, "matchMedia", {
|
|
writable: true,
|
|
configurable: true,
|
|
value: (q: string) => ({
|
|
matches: q.includes("reduce") ? matches : false,
|
|
media: q,
|
|
onchange: null,
|
|
addListener: () => {},
|
|
removeListener: () => {},
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
dispatchEvent: () => false,
|
|
}),
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockSaveQuestionnaire.mockReset();
|
|
mockSaveQuestionnaire.mockResolvedValue(undefined);
|
|
setUser(null);
|
|
wipeDismissCounters();
|
|
mockPrefersReducedMotion(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
wipeDismissCounters();
|
|
});
|
|
|
|
function renderModal() {
|
|
return render(
|
|
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
|
<SourceBackfillModal />
|
|
</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
describe("SourceBackfillModal", () => {
|
|
it("does not render when there is no user", () => {
|
|
renderModal();
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not render when the user already recorded a source", () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: ["search"] },
|
|
});
|
|
renderModal();
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("opens for an onboarded user with empty source", async () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
renderModal();
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(/How did you hear about Multica/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("Submit PATCHes the merged questionnaire preserving role / use_case", async () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: {
|
|
source: [],
|
|
role: "engineer",
|
|
role_skipped: false,
|
|
use_case: ["ship_code", "plan_research"],
|
|
use_case_skipped: false,
|
|
version: 2,
|
|
},
|
|
});
|
|
const user = userEvent.setup();
|
|
renderModal();
|
|
await user.click(await screen.findByText("Friends or colleagues"));
|
|
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockSaveQuestionnaire).toHaveBeenCalledTimes(1);
|
|
});
|
|
const sent = mockSaveQuestionnaire.mock.calls[0]![0];
|
|
expect(sent.source).toEqual(["friends_colleagues"]);
|
|
expect(sent.source_skipped).toBe(false);
|
|
expect(sent.role).toBe("engineer");
|
|
expect(sent.use_case).toEqual(["ship_code", "plan_research"]);
|
|
expect(sent.version).toBe(2);
|
|
});
|
|
|
|
it("Skip PATCHes source_skipped=true preserving role / use_case", async () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: {
|
|
source: [],
|
|
role: "founder",
|
|
use_case: ["manage_team"],
|
|
version: 2,
|
|
},
|
|
});
|
|
const user = userEvent.setup();
|
|
renderModal();
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "Skip" }),
|
|
);
|
|
await waitFor(() => {
|
|
expect(mockSaveQuestionnaire).toHaveBeenCalledTimes(1);
|
|
});
|
|
const sent = mockSaveQuestionnaire.mock.calls[0]![0];
|
|
expect(sent.source).toEqual([]);
|
|
expect(sent.source_skipped).toBe(true);
|
|
expect(sent.role).toBe("founder");
|
|
expect(sent.use_case).toEqual(["manage_team"]);
|
|
});
|
|
|
|
it("treats a legacy single-string source as already answered", () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: "search" },
|
|
});
|
|
renderModal();
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the GitHub channel rebased from origin/main", async () => {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
renderModal();
|
|
expect(await screen.findByText("GitHub")).toBeInTheDocument();
|
|
});
|
|
|
|
it("picking a second option replaces the first (single-select primary source)", async () => {
|
|
// The modal is now a single-select radio. Industry default for
|
|
// HDYHAU is to capture the primary acquisition source, so picking
|
|
// a second option must replace the first — never accumulate.
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
const user = userEvent.setup();
|
|
renderModal();
|
|
await screen.findByText("Friends or colleagues");
|
|
const radios = screen.getAllByRole("radio");
|
|
const friends = radios[0]!;
|
|
const search = radios[1]!;
|
|
|
|
await user.click(friends);
|
|
expect(friends).toHaveAttribute("aria-checked", "true");
|
|
expect(search).toHaveAttribute("aria-checked", "false");
|
|
|
|
// Pick a second option — the first must clear and Submit stays
|
|
// enabled with exactly one pick in the payload.
|
|
await user.click(search);
|
|
expect(friends).toHaveAttribute("aria-checked", "false");
|
|
expect(search).toHaveAttribute("aria-checked", "true");
|
|
|
|
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
await waitFor(() => {
|
|
expect(mockSaveQuestionnaire).toHaveBeenCalledTimes(1);
|
|
});
|
|
const sent = mockSaveQuestionnaire.mock.calls[0]![0];
|
|
// Server schema is still `source: string[]` for back-compat with
|
|
// v2 rows; the client always sends a single-element array.
|
|
expect(sent.source).toEqual(["search"]);
|
|
expect(sent.source).not.toContain("friends_colleagues");
|
|
});
|
|
|
|
it("defers the entrance by ~700ms when the user has not opted into reduced motion", async () => {
|
|
mockPrefersReducedMotion(false);
|
|
vi.useFakeTimers();
|
|
try {
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
renderModal();
|
|
// Immediately after mount: still hidden — the workspace gets a
|
|
// beat to render before the modal floats in.
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(699);
|
|
});
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
});
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).toBeInTheDocument();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("does not open once the per-user dismiss cap is reached on this browser", () => {
|
|
window.localStorage.setItem("multica.source_backfill.dismiss.u1", "3");
|
|
setUser({
|
|
id: "u1",
|
|
onboarded_at: "2026-01-01T00:00:00Z",
|
|
onboarding_questionnaire: { source: [] },
|
|
});
|
|
renderModal();
|
|
expect(
|
|
screen.queryByText(/How did you hear about Multica/i),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|