mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* fix(web): report route labels as GA page_title instead of workspace titles Co-authored-by: multica-agent <[email protected]> * fix(web): pin GA page_title via set so navigation updates take effect Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
170 lines
6.7 KiB
TypeScript
170 lines
6.7 KiB
TypeScript
import {
|
|
analyticsPageTitle,
|
|
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.
|
|
*
|
|
* page_title is pinned the same way. GA would otherwise read document.title,
|
|
* which in a workspace is user content (Issue titles, project names). Every
|
|
* event instead carries a route label from analyticsPageTitle, set before
|
|
* GA's own history listener observes a navigation.
|
|
*/
|
|
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());
|
|
// page_title lives in the global `set` scope only, never in `config`:
|
|
// config parameters take precedence over set, so a config page_title
|
|
// would freeze the first label and ignore pinPageTitle on navigation.
|
|
w.gtag("set", { page_title: analyticsPageTitle(w.location.href) });
|
|
// 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);
|
|
};
|
|
|
|
// Re-pin page_title for the URL GA is about to observe. gtag.js wraps the
|
|
// history methods after this module does, so its wrapper calls ours first
|
|
// and sees the label already set when it reports the navigation.
|
|
const pinPageTitle = (url: string) => {
|
|
if (!loaded || stopped || !w.gtag) return;
|
|
w.gtag("set", { page_title: analyticsPageTitle(url) });
|
|
};
|
|
|
|
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();
|
|
}
|
|
pinPageTitle(target);
|
|
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();
|
|
pinPageTitle(w.location.href);
|
|
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);
|
|
}
|