mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 00:19:29 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e7b19303 |
52
apps/web/components/pageview-tracker.test.tsx
Normal file
52
apps/web/components/pageview-tracker.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// Mutable pathname + a spy for the shared capture helper. The tracker reads
|
||||
// usePathname() and forwards it to capturePageview; section-normalization and
|
||||
// dedup live in @multica/core/analytics and are unit-tested there, so here we
|
||||
// only assert the wiring (which path is forwarded, and that the query string
|
||||
// never re-triggers the effect).
|
||||
const { state, capturePageview } = vi.hoisted(() => ({
|
||||
state: { pathname: "/" as string | null },
|
||||
capturePageview: vi.fn<(path?: string) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => state.pathname,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/analytics", () => ({
|
||||
capturePageview,
|
||||
}));
|
||||
|
||||
import { PageviewTracker } from "./pageview-tracker";
|
||||
|
||||
beforeEach(() => {
|
||||
state.pathname = "/";
|
||||
capturePageview.mockClear();
|
||||
});
|
||||
|
||||
describe("web PageviewTracker", () => {
|
||||
it("captures the pathname on mount and on each pathname change", () => {
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
expect(capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(capturePageview).toHaveBeenLastCalledWith("/");
|
||||
|
||||
state.pathname = "/acme/issues";
|
||||
rerender(<PageviewTracker />);
|
||||
expect(capturePageview).toHaveBeenCalledTimes(2);
|
||||
expect(capturePageview).toHaveBeenLastCalledWith("/acme/issues");
|
||||
});
|
||||
|
||||
it("does not re-capture on a query-string-only navigation", () => {
|
||||
state.pathname = "/acme/issues";
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
expect(capturePageview).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A filter/sort/search change alters only the query string, which the
|
||||
// tracker no longer reads — usePathname() is unchanged so the effect's
|
||||
// dependency does not change and no new pageview fires.
|
||||
rerender(<PageviewTracker />);
|
||||
expect(capturePageview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the Next.js App Router path or query
|
||||
* string changes. Mounted once at the root so every route transition is
|
||||
* covered, including transitions into workspace-scoped subtrees.
|
||||
* Fires a PostHog $pageview whenever the Next.js App Router pathname changes.
|
||||
* Mounted once at the root so every route transition is covered, including
|
||||
* transitions into workspace-scoped subtrees.
|
||||
*
|
||||
* PostHog's own `capture_pageview: true` auto-capture is deliberately
|
||||
* disabled in `initAnalytics` so we own the event shape — this component
|
||||
* is what actually fires the event. Before this existed the acquisition
|
||||
* funnel's `/ → signup` step was empty.
|
||||
* Deliberately keyed on `pathname` only — NOT `useSearchParams`. Filter / sort
|
||||
* / search state lives in the query string and changes constantly on a
|
||||
* dashboard; firing a pageview on every query-string change was ~17% pure
|
||||
* noise (and billed events) with no funnel signal. The query string is also
|
||||
* dropped from the captured URL by `capturePageview` (it section-normalizes
|
||||
* the path), so OAuth `code` / `state` never reach PostHog either.
|
||||
*
|
||||
* PostHog's own `capture_pageview: true` auto-capture is deliberately disabled
|
||||
* in `initAnalytics` so this component owns the event shape.
|
||||
*/
|
||||
export function PageviewTracker() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
const qs = searchParams?.toString();
|
||||
const url = qs ? `${pathname}?${qs}` : pathname;
|
||||
capturePageview(url);
|
||||
}, [pathname, searchParams]);
|
||||
if (pathname) capturePageview(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -531,11 +531,20 @@ sent from a pre-workspace surface.
|
||||
|
||||
### Frontend-only events
|
||||
|
||||
- `$pageview` — fired by `apps/web/components/pageview-tracker.tsx` on
|
||||
every Next.js App Router path or query-string change. The tracker
|
||||
mounts once under `WebProviders` and drives the acquisition funnel's
|
||||
- `$pageview` — fired by the web tracker
|
||||
(`apps/web/components/pageview-tracker.tsx`) on Next.js App Router
|
||||
**pathname** changes, and by the desktop tracker
|
||||
(`apps/desktop/.../pageview-tracker.tsx`) on visible-surface changes.
|
||||
Both mount once at the root and drive the acquisition funnel's
|
||||
`/ → signup` step. posthog-js's automatic pageview capture is
|
||||
disabled in `initAnalytics` so we own the event shape.
|
||||
`capturePageview` (`packages/core/analytics`) **section-normalizes** the
|
||||
path before emitting: query string / hash are stripped and resource-id
|
||||
segments are collapsed, so `/acme/issues/8d5c…` and `/acme/issues/MUL-12`
|
||||
both report as `/acme/issues`, and consecutive views of the same section
|
||||
are deduplicated. This keeps PostHog at section granularity rather than
|
||||
billing a `$pageview` per resource or per filter/sort/search change. The
|
||||
tracker is deliberately NOT keyed on the query string.
|
||||
- `onboarding_runtime_path_selected` — fired from
|
||||
`packages/views/onboarding/steps/step-platform-fork.tsx` when the web
|
||||
user clicks one of the three Step 3 fork cards (before any server
|
||||
|
||||
@@ -102,3 +102,84 @@ describe("resetAnalytics", () => {
|
||||
expect(posthog.register).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePageviewPath", () => {
|
||||
it("collapses resource-id segments to the section route", async () => {
|
||||
const { analytics } = await loadModule();
|
||||
expect(
|
||||
analytics.normalizePageviewPath("/acme/issues/8d5c1a2b-0035-4c62-9f14-1ad4215736a5"),
|
||||
).toBe("/acme/issues");
|
||||
expect(analytics.normalizePageviewPath("/acme/issues/MUL-123")).toBe("/acme/issues");
|
||||
expect(
|
||||
analytics.normalizePageviewPath("/invite/8d5c1a2b-0035-4c62-9f14-1ad4215736a5"),
|
||||
).toBe("/invite");
|
||||
});
|
||||
|
||||
it("strips query string and hash", async () => {
|
||||
const { analytics } = await loadModule();
|
||||
expect(analytics.normalizePageviewPath("/acme/issues?status=open&view=board")).toBe(
|
||||
"/acme/issues",
|
||||
);
|
||||
expect(analytics.normalizePageviewPath("/acme/issues#section")).toBe("/acme/issues");
|
||||
});
|
||||
|
||||
it("keeps non-id sub-sections and never drops the leading segment", async () => {
|
||||
const { analytics } = await loadModule();
|
||||
expect(analytics.normalizePageviewPath("/acme/settings/members")).toBe(
|
||||
"/acme/settings/members",
|
||||
);
|
||||
// A workspace slug that looks like an issue key must not be dropped.
|
||||
expect(analytics.normalizePageviewPath("/team-1/issues/MUL-9")).toBe("/team-1/issues");
|
||||
expect(analytics.normalizePageviewPath("/login")).toBe("/login");
|
||||
expect(analytics.normalizePageviewPath("/")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capturePageview", () => {
|
||||
function captureMock(posthog: unknown) {
|
||||
return (posthog as { capture: ReturnType<typeof vi.fn> }).capture;
|
||||
}
|
||||
|
||||
it("emits the section-normalized path as $current_url", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
const capture = captureMock(posthog);
|
||||
capture.mockClear();
|
||||
|
||||
analytics.capturePageview("/acme/issues/8d5c1a2b-0035-4c62-9f14-1ad4215736a5");
|
||||
|
||||
expect(capture).toHaveBeenCalledTimes(1);
|
||||
expect(capture).toHaveBeenCalledWith("$pageview", { $current_url: "/acme/issues" });
|
||||
});
|
||||
|
||||
it("dedupes consecutive views of the same section but fires on section change", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
const capture = captureMock(posthog);
|
||||
capture.mockClear();
|
||||
|
||||
// Two different issues collapse to the same section → one event.
|
||||
analytics.capturePageview("/acme/issues/a1b2c3d4-0035-4c62-9f14-1ad4215736a5");
|
||||
analytics.capturePageview("/acme/issues/b2c3d4e5-0035-4c62-9f14-1ad4215736a5");
|
||||
expect(capture).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A real section change fires again.
|
||||
analytics.capturePageview("/acme/projects");
|
||||
expect(capture).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-emits the same section after resetAnalytics clears the dedup state", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
const capture = captureMock(posthog);
|
||||
capture.mockClear();
|
||||
|
||||
analytics.capturePageview("/acme/inbox");
|
||||
analytics.capturePageview("/acme/inbox");
|
||||
expect(capture).toHaveBeenCalledTimes(1);
|
||||
|
||||
analytics.resetAnalytics();
|
||||
analytics.capturePageview("/acme/inbox");
|
||||
expect(capture).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,12 @@ let analyticsEnvironment = "dev";
|
||||
// config fetch resolves. We keep the first pending pageview so that step
|
||||
// doesn't silently drop.
|
||||
let pendingPageview: string | undefined | null = null;
|
||||
// Last $pageview path actually emitted (already section-normalized). Used to
|
||||
// collapse consecutive views of the same section so navigating between
|
||||
// resources under one section doesn't fire a billed event per resource. See
|
||||
// capturePageview / normalizePageviewPath. Cleared on reset so a fresh
|
||||
// session re-emits its first pageview.
|
||||
let lastCapturedPath: string | 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
|
||||
@@ -167,6 +173,7 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
// And any first pageview we captured while config was loading.
|
||||
if (pendingPageview !== null) {
|
||||
posthog.capture("$pageview", pendingPageview ? { $current_url: pendingPageview } : undefined);
|
||||
lastCapturedPath = pendingPageview ?? null;
|
||||
pendingPageview = null;
|
||||
}
|
||||
// Replay buffered events / person-property updates in their original
|
||||
@@ -210,6 +217,7 @@ export function resetAnalytics(): void {
|
||||
currentUserId = null;
|
||||
pendingIdentify = null;
|
||||
pendingPageview = null;
|
||||
lastCapturedPath = null;
|
||||
pendingOps.length = 0;
|
||||
if (!initialized) return;
|
||||
posthog.reset();
|
||||
@@ -304,11 +312,43 @@ function normalizeEnvironment(value: string | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
// A UUID or an issue key (e.g. MUL-123) appearing as a path segment
|
||||
// identifies a single resource. Resource-level granularity carries no
|
||||
// aggregate funnel signal and explodes $pageview volume — every distinct
|
||||
// issue / agent / project navigated to would fire its own billed event.
|
||||
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const ISSUE_KEY_SEGMENT = /^[A-Z][A-Z0-9]*-\d+$/;
|
||||
|
||||
/**
|
||||
* Normalize a raw path to its section route for $pageview reporting:
|
||||
* `/acme/issues/8d5c…` and `/acme/issues/MUL-12` both collapse to
|
||||
* `/acme/issues`. We strip the query string / hash (volatile filter / sort /
|
||||
* search state, and occasionally OAuth `code` / `state`) and drop any
|
||||
* resource-id segment after the first. The leading segment — the workspace
|
||||
* slug or a top-level route word like `login` — is always kept, so a slug
|
||||
* that happens to look like an id (`team-1`) is never dropped.
|
||||
*
|
||||
* Exported for unit testing; callers should go through capturePageview.
|
||||
*/
|
||||
export function normalizePageviewPath(path?: string): string | undefined {
|
||||
if (!path) return path ?? undefined;
|
||||
const clean = path.split(/[?#]/)[0] ?? "";
|
||||
const segments = clean.split("/").filter((s) => s.length > 0);
|
||||
const kept = segments.filter(
|
||||
(seg, i) => i === 0 || !(UUID_SEGMENT.test(seg) || ISSUE_KEY_SEGMENT.test(seg)),
|
||||
);
|
||||
return "/" + kept.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* event shape.
|
||||
*
|
||||
* The path is normalized to its section route (see normalizePageviewPath) and
|
||||
* consecutive views of the same section are collapsed — both keep PostHog at
|
||||
* section granularity instead of paying for a billed event per resource and
|
||||
* per query-string change. Callers can therefore pass the raw path freely.
|
||||
*
|
||||
* Calls before initAnalytics() buffer the most-recent path so the first
|
||||
* pageview isn't dropped on slow /api/config fetches. Subsequent pre-init
|
||||
@@ -316,11 +356,14 @@ function normalizeEnvironment(value: string | undefined): string {
|
||||
* captures synchronously as expected.
|
||||
*/
|
||||
export function capturePageview(path?: string): void {
|
||||
const normalized = normalizePageviewPath(path);
|
||||
if (!initialized) {
|
||||
pendingPageview = path ?? "";
|
||||
pendingPageview = normalized ?? "";
|
||||
return;
|
||||
}
|
||||
posthog.capture("$pageview", path ? { $current_url: path } : undefined);
|
||||
if (normalized && normalized === lastCapturedPath) return;
|
||||
lastCapturedPath = normalized ?? null;
|
||||
posthog.capture("$pageview", normalized ? { $current_url: normalized } : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user