Compare commits

...

1 Commits

Author SHA1 Message Date
J
a2e7b19303 perf(analytics): report $pageview at section granularity, drop web query-string churn
capturePageview now section-normalizes the path (strip query/hash, collapse
UUID and issue-key resource segments) and dedupes consecutive same-section
views, so navigating between issues/agents/etc. no longer fires a billed
PostHog event per resource. The web tracker keys on pathname only (not
searchParams), removing ~17% pure query-string-churn pageviews and keeping
OAuth code/state out of $current_url.

MUL-3081

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 15:22:22 +08:00
5 changed files with 207 additions and 21 deletions

View 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);
});
});

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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);
}
/**