MUL-8253 feat(web): opt-in Google Analytics via GOOGLE_ANALYTICS_ID (#9544)

* feat(web): opt-in Google Analytics via GOOGLE_ANALYTICS_ID (MUL-8253)

Co-authored-by: multica-agent <[email protected]>

* fix(i18n): add missing zh-Hant keys for model override and scheduled wakeup

Co-authored-by: multica-agent <[email protected]>

* fix(web): keep credential-bearing URLs out of Google Analytics

Co-authored-by: multica-agent <[email protected]>

* feat(selfhost): pass GOOGLE_ANALYTICS_ID through compose and helm

Co-authored-by: multica-agent <[email protected]>

* fix(web): stop GA for the document after sensitive navigation

Co-authored-by: multica-agent <[email protected]>

* test(views): await subscription cleanup and dialog focus

Co-authored-by: multica-agent <[email protected]>

* fix(web): stop GA before credential-bearing link clicks and form submits

GA4 enhanced measurement reports outbound/download link_url and
form_destination without a URL change, so signed attachment downloads
leaked their signature. Stop the tag for the document in a window
capture listener before gtag's own listeners observe the interaction.
Object URLs are exempt. Adds jsdom tests and a live gtag.js regression
covering the ~5s batch delay.

Co-authored-by: multica-agent <[email protected]>

* test(web): slim GA tests; move views test fixes to #9555

Drop the opt-in live-tag e2e spec (never runs in CI) and condense the GA
unit tests to the cases that guard against credential leaks. The two
unrelated views test-timing fixes now live in #9555.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
This commit is contained in:
Multica Eve
2026-10-26 18:04:40 +08:00
committed by GitHub
co-authored by multica-agent Bohan-J
parent 9b78e3c309
commit 2213734215
10 changed files with 353 additions and 0 deletions
+5
View File
@@ -667,6 +667,11 @@ MULTICA_TELEGRAM_SECRET_KEY=
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=
# Google Analytics (optional) — GA4 Measurement ID, e.g. G-XXXXXXXXXX.
# Leave empty to keep gtag.js off (the default for local dev and self-hosting).
# Read by the Next.js server at runtime, so no web rebuild is needed to toggle it.
GOOGLE_ANALYTICS_ID=
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai
+5
View File
@@ -13,6 +13,8 @@ import {
resolveBrowserApiBaseUrl,
resolveBrowserWsUrl,
} from "@/config/runtime-urls";
import { resolveGoogleAnalyticsId } from "@/config/google-analytics";
import { GoogleAnalytics } from "@/components/google-analytics";
import "./globals.css";
// Inter is the Latin UI face. next/font produces a hashed family (`__Inter_xxx`)
@@ -128,6 +130,8 @@ export default async function RootLayout({
const resources = { [locale]: RESOURCES[locale] };
const apiBaseUrl = resolveBrowserApiBaseUrl(process.env);
const wsUrl = resolveBrowserWsUrl(process.env);
// Opt-in Google Analytics: omitted entirely unless GOOGLE_ANALYTICS_ID is set.
const googleAnalyticsId = resolveGoogleAnalyticsId(process.env);
return (
<html
@@ -165,6 +169,7 @@ export default async function RootLayout({
</WebProviders>
<Toaster />
</ThemeProvider>
{googleAnalyticsId && <GoogleAnalytics measurementId={googleAnalyticsId} />}
</body>
</html>
);
@@ -0,0 +1,59 @@
import { render } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { GoogleAnalytics } from "./google-analytics";
// Guards the privacy gate in lib/google-analytics.ts. A regression here would
// not show up as an error; credentials would silently reach Google.
const ID = "G-TEST1234";
const w = window as unknown as Record<string, unknown>;
const disabled = () => w[`ga-disable-${ID}`];
const scripts = () => document.querySelectorAll('script[src*="googletagmanager.com/gtag/js"]').length;
const { pushState, replaceState } = window.history;
afterEach(() => {
Object.assign(window.history, { pushState, replaceState });
for (const k of ["__multicaGoogleAnalytics", `ga-disable-${ID}`, "dataLayer", "gtag"]) delete w[k];
document.querySelectorAll("script").forEach((s) => s.remove());
window.history.replaceState(null, "", "/");
});
const start = (path: string) => {
window.history.replaceState(null, "", path);
render(<GoogleAnalytics measurementId={ID} />);
};
const click = (href: string) => {
const a = document.createElement("a");
a.href = href;
a.addEventListener("click", (e) => e.preventDefault()); // jsdom can't navigate
document.body.appendChild(a);
a.click();
a.remove();
};
it("loads only once on a clean URL, never on a sensitive landing", () => {
start("/wecom/bind?token=secret");
expect(scripts()).toBe(0);
window.history.pushState(null, "", "/acme/issues");
window.history.pushState(null, "", "/acme/projects");
expect(scripts()).toBe(1);
expect(disabled()).toBe(false);
});
it("stops for the document after visiting a sensitive URL", () => {
start("/about");
window.history.pushState(null, "", "/auth/callback?code=c");
window.history.pushState(null, "", "/privacy");
window.history.replaceState(null, "", "/privacy");
expect(disabled()).toBe(true);
});
it("stops before a signed download click, but not for clean or blob links", () => {
start("/about");
click("https://github.com/multica-ai/multica");
click("blob:https://app.example.invalid/uuid");
expect(disabled()).toBe(false);
click("https://cdn.example.invalid/a.pdf?Signature=s");
expect(disabled()).toBe(true);
});
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { useEffect } from "react";
import { installGoogleAnalytics } from "@/lib/google-analytics";
export function GoogleAnalytics({ measurementId }: { measurementId: string }) {
useEffect(() => {
installGoogleAnalytics(measurementId);
}, [measurementId]);
return null;
}
+19
View File
@@ -0,0 +1,19 @@
// @vitest-environment node
import { expect, it } from "vitest";
import { resolveGoogleAnalyticsId, sanitizeAnalyticsUrl } from "./google-analytics";
it("enables only a valid GA4 measurement ID", () => {
expect(resolveGoogleAnalyticsId({})).toBeUndefined();
expect(resolveGoogleAnalyticsId({ GOOGLE_ANALYTICS_ID: " g-rqvf209ehd " })).toBe("G-RQVF209EHD");
// Interpolated into an inline script, so anything else is rejected.
expect(resolveGoogleAnalyticsId({ GOOGLE_ANALYTICS_ID: "G-X'); alert(1); //" })).toBeUndefined();
expect(resolveGoogleAnalyticsId({ GOOGLE_ANALYTICS_ID: "UA-12345-1" })).toBeUndefined();
});
it("strips credentials from URLs but keeps campaign params", () => {
expect(sanitizeAnalyticsUrl("https://multica.ai/wecom/bind?token=t")).toBe("https://multica.ai/wecom/bind");
expect(sanitizeAnalyticsUrl("https://multica.ai/invite/inv_1")).toBe("https://multica.ai/invite/[id]");
expect(sanitizeAnalyticsUrl("https://multica.ai/?utm_source=x&code=c")).toBe("https://multica.ai/?utm_source=x");
expect(sanitizeAnalyticsUrl("javascript:alert(1)")).toBe("");
});
+100
View File
@@ -0,0 +1,100 @@
type RuntimeEnv = Record<string, string | undefined>;
// GA4 Measurement IDs look like `G-RQVF209EHD`. The value is interpolated
// into the gtag.js URL and config call, so anything that doesn't match this
// shape is rejected outright rather than escaped.
const MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{4,20}$/;
/**
* Resolve the Google Analytics (GA4) Measurement ID from the runtime env.
*
* Opt-in: returns undefined unless GOOGLE_ANALYTICS_ID is set to a valid
* GA4 ID, so local dev and self-hosted images never load gtag.js. It is read
* by the Next.js server per request (not a NEXT_PUBLIC_* build-time var), so
* the published Docker images carry no ID and operators can toggle it without
* a rebuild.
*/
export function resolveGoogleAnalyticsId(env: RuntimeEnv): string | undefined {
const value = env.GOOGLE_ANALYTICS_ID?.trim().toUpperCase();
if (!value || !MEASUREMENT_ID_PATTERN.test(value)) return undefined;
return value;
}
// Query params that are safe and useful to report. Everything else is
// dropped: the app carries live credentials in the query string — IM bind
// tokens (`/wecom/bind?token=…` and friends), OAuth `code`/`state` on
// `/auth/callback`, CLI login `cli_callback`/`cli_state`, `next` redirect
// targets — and GA would otherwise record the full URL (page_location /
// page_referrer). An allowlist keeps new secret-bearing params out by default.
const ALLOWED_QUERY_PARAMS = new Set([
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"gclid",
]);
// Path segments that are capabilities rather than page names. Invitation ids
// are bearer-ish links, so report the route shape, not the value.
const REDACTED_PATH_PATTERNS: Array<[RegExp, string]> = [
[/^\/invite\/[^/]+/, "/invite/[id]"],
];
/**
* Reduce a URL to what is safe to send to Google Analytics: origin + path
* (with capability segments masked) + allowlisted query params. The hash is
* always dropped. Returns "" for empty or unparseable input, which GA treats
* as "not set".
*/
export function sanitizeAnalyticsUrl(raw: string | undefined | null): string {
const parts = analyticsUrlParts(raw);
if (!parts) return "";
return parts.sanitized;
}
/**
* True when the raw URL carries something sanitizeAnalyticsUrl would strip
* (a non-allowlisted query param or a capability path segment). GA's own
* history-change page views read the live URL directly, so the component
* stops GA for this document once the loaded tag may have observed one.
*/
export function isSensitiveAnalyticsUrl(raw: string | undefined | null): boolean {
if (!raw) return false;
const parts = analyticsUrlParts(raw);
return !parts || parts.sensitive;
}
function analyticsUrlParts(
raw: string | undefined | null,
): { sanitized: string; sensitive: boolean } | null {
if (!raw) return null;
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
let sensitive = false;
let pathname = url.pathname;
for (const [pattern, replacement] of REDACTED_PATH_PATTERNS) {
if (pattern.test(pathname)) {
sensitive = true;
pathname = pathname.replace(pattern, replacement);
}
}
const kept = new URLSearchParams();
url.searchParams.forEach((value, key) => {
if (ALLOWED_QUERY_PARAMS.has(key)) kept.append(key, value);
else sensitive = true;
});
const search = kept.toString();
return {
sanitized: `${url.origin}${pathname}${search ? `?${search}` : ""}`,
sensitive,
};
}
+146
View File
@@ -0,0 +1,146 @@
import { isSensitiveAnalyticsUrl, sanitizeAnalyticsUrl } from "../config/google-analytics";
type Gtag = (...args: unknown[]) => void;
type GaWindow = Window & {
dataLayer?: unknown[];
gtag?: Gtag;
__multicaGoogleAnalytics?: string;
[disableKey: `ga-disable-${string}`]: boolean | undefined;
};
/**
* Google tag (gtag.js) for GA4. Rendered only when the root layout resolved a
* Measurement ID from GOOGLE_ANALYTICS_ID (config/google-analytics.ts).
*
* GA's automatic history events can retain a sensitive referrer after the
* browser has left it. Once the tag may have observed a sensitive URL, keep
* its documented opt-out flag set for the rest of this document's lifetime.
* Re-enabling it on a later history event can release credential-bearing
* events, including when the tag's download finished on a sensitive URL.
* A fresh document can start again, with its landing referrer sanitized.
* The same applies to activating a credential-bearing link or form (e.g. a
* signed attachment download), which GA reports without a URL change.
*/
export function installGoogleAnalytics(measurementId: string) {
const w = window as unknown as GaWindow;
// Install once per page: the root layout never unmounts, and StrictMode /
// Fast Refresh re-runs must not stack history hooks or reload gtag.js.
if (w.__multicaGoogleAnalytics === measurementId) return;
w.__multicaGoogleAnalytics = measurementId;
const disableKey = `ga-disable-${measurementId}` as const;
let loaded = false;
let stopped = false;
const stop = () => {
stopped = true;
w[disableKey] = true;
};
const load = () => {
loaded = true;
w[disableKey] = false;
w.dataLayer = w.dataLayer || [];
w.gtag = function gtag() {
// gtag.js consumes the `arguments` objects queued here (Google's shim).
// eslint-disable-next-line prefer-rest-params
w.dataLayer!.push(arguments);
};
w.gtag("js", new Date());
// The landing referrer may be a same-origin secret URL (full-page hop
// off /login?cli_state=…); pin a sanitized copy instead.
w.gtag("config", measurementId, {
page_location: sanitizeAnalyticsUrl(w.location.href),
page_referrer: sanitizeAnalyticsUrl(document.referrer),
});
const script = document.createElement("script");
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`;
document.head.appendChild(script);
};
const maybeLoad = () => {
if (stopped || loaded) return;
if (isSensitiveAnalyticsUrl(w.location.href)) {
w[disableKey] = true;
return;
}
load();
};
const originalPush = w.history.pushState;
const originalReplace = w.history.replaceState;
const wrap =
(original: History["pushState"]): History["pushState"] =>
function (this: History, data, unused, url) {
const target = url == null ? w.location.href : new URL(String(url), w.location.href).href;
// Stop before any tag history listener can observe the new URL.
if (loaded && (isSensitiveAnalyticsUrl(w.location.href) || isSensitiveAnalyticsUrl(target))) {
stop();
}
const result = original.call(this, data, unused, url);
// Initial loading must happen after a successful navigation, with the
// real location already clean. No tag has observed earlier URLs yet.
maybeLoad();
return result;
};
w.history.pushState = wrap(originalPush);
w.history.replaceState = wrap(originalReplace);
// Capture phase, registered before gtag.js: runs ahead of gtag's listener.
const onPopState = () => {
if (loaded && isSensitiveAnalyticsUrl(w.location.href)) stop();
maybeLoad();
};
w.addEventListener("popstate", onPopState, true);
// GA's enhanced measurement also reports the full target URL of outbound
// link clicks and file downloads (`link_url`), and the form action of form
// submissions (`form_destination`) — without the page URL changing. Signed
// attachment downloads (CloudFront / S3 presign / proxy capability) click a
// credential-bearing link, so stop before gtag's own listeners see such an
// interaction. Window capture runs ahead of any document-level listener.
const onLinkInteraction = (event: Event) => {
if (!loaded || stopped) return;
const href = interactionLinkHref(event);
if (href && isSensitiveInteractionUrl(href)) stop();
};
w.addEventListener("click", onLinkInteraction, true);
w.addEventListener("auxclick", onLinkInteraction, true);
w.addEventListener(
"submit",
(event) => {
if (!loaded || stopped) return;
const form = event.target;
if (form instanceof HTMLFormElement && isSensitiveInteractionUrl(form.action)) stop();
},
true,
);
maybeLoad();
}
// The href of the link an interaction activates, resolved to an absolute URL.
// composedPath() also reaches anchors inside open shadow roots and SVG links.
function interactionLinkHref(event: Event): string | null {
for (const node of event.composedPath()) {
if (!(node instanceof Element)) continue;
if (node.localName !== "a" && node.localName !== "area") continue;
const raw = node.getAttribute("href") ?? node.getAttribute("xlink:href");
if (raw == null) continue;
try {
return new URL(raw, document.baseURI).href;
} catch {
// Unparseable: treat as opaque and let the sensitivity check decide.
return raw;
}
}
return null;
}
// Object URLs (CSV / diagram exports) are an opaque same-origin UUID and carry
// no credential; everything else uses the page-URL rule (non-allowlisted query
// params, capability paths, or a non-http(s) scheme).
function isSensitiveInteractionUrl(url: string): boolean {
if (url.startsWith("blob:")) return false;
return isSensitiveAnalyticsUrl(url);
}
@@ -46,6 +46,10 @@ spec:
- name: NEXT_PUBLIC_WS_URL
value: {{ .Values.frontend.config.publicWsUrl | quote }}
{{- end }}
{{- if .Values.frontend.config.googleAnalyticsId }}
- name: GOOGLE_ANALYTICS_ID
value: {{ .Values.frontend.config.googleAnalyticsId | quote }}
{{- end }}
resources:
{{- toYaml .Values.frontend.resources | nindent 12 }}
{{- with .Values.frontend.affinity }}
+2
View File
@@ -197,6 +197,8 @@ frontend:
# default same-origin frontend proxy.
publicApiUrl: ""
publicWsUrl: ""
# Optional GA4 Measurement ID (G-XXXXXXXXXX). Empty keeps gtag.js off.
googleAnalyticsId: ""
# Compatibility shim for legacy multica-web images built before runtime
# URL rewrites. New images do not need this unprefixed Service.
compatibility:
+1
View File
@@ -231,6 +231,7 @@ services:
DOCS_URL: ${DOCS_URL:-}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
GOOGLE_ANALYTICS_ID: ${GOOGLE_ANALYTICS_ID:-}
restart: unless-stopped
volumes: