From d16d95b71b4a1a45904a6978cebc3dddf8d05a73 Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Fri, 9 Oct 2026 22:56:53 +0800 Subject: [PATCH] fix(analytics): preserve Brain attribution and relay embedded events --- apps/ui/src/app/deploy/page.tsx | 29 +++--- apps/ui/src/app/page.tsx | 12 ++- .../brain-analytics-transport.test.ts | 90 +++++++++++++++++++ .../analytics/brain-analytics-transport.ts | 45 ++++++++++ apps/ui/src/features/analytics/brain-gtm.ts | 4 +- .../features/marketing/redirect-query.test.ts | 41 +++++++++ .../src/features/marketing/redirect-query.ts | 19 ++++ apps/ui/src/features/shell/auth-bootstrap.tsx | 25 ++++++ 8 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 apps/ui/src/features/analytics/brain-analytics-transport.test.ts create mode 100644 apps/ui/src/features/analytics/brain-analytics-transport.ts create mode 100644 apps/ui/src/features/marketing/redirect-query.test.ts create mode 100644 apps/ui/src/features/marketing/redirect-query.ts diff --git a/apps/ui/src/app/deploy/page.tsx b/apps/ui/src/app/deploy/page.tsx index 86b84369..dff06641 100644 --- a/apps/ui/src/app/deploy/page.tsx +++ b/apps/ui/src/app/deploy/page.tsx @@ -1,23 +1,32 @@ import { redirect } from "next/navigation"; import { githubDeployProjectPath } from "@/features/deploy/github-deploy-link"; import { templateDeployProjectPath } from "@/features/deploy/template-deploy-link"; +import { + appendMarketingRedirectQuery, + type MarketingRedirectQuery, +} from "@/features/marketing/redirect-query"; export default async function DeployPage({ searchParams, }: { - searchParams: Promise<{ - autoDeploy?: string | string[]; - githubRepo?: string | string[]; - templateForm?: string | string[]; - templateName?: string | string[]; - }>; + searchParams: Promise; }) { - const { autoDeploy, githubRepo, templateForm, templateName } = - await searchParams; + const query = await searchParams; + const { autoDeploy, githubRepo, templateForm, templateName } = query; // Only a single, non-empty repo value may claim the GitHub deep link; an // empty or repeated `githubRepo` must not steal a parallel template link. if (typeof githubRepo === "string" && githubRepo.trim() !== "") { - redirect(githubDeployProjectPath(githubRepo, autoDeploy)); + redirect( + appendMarketingRedirectQuery( + githubDeployProjectPath(githubRepo, autoDeploy), + query + ) + ); } - redirect(templateDeployProjectPath(templateName, templateForm)); + redirect( + appendMarketingRedirectQuery( + templateDeployProjectPath(templateName, templateForm), + query + ) + ); } diff --git a/apps/ui/src/app/page.tsx b/apps/ui/src/app/page.tsx index fffe5d76..a49eb8ed 100644 --- a/apps/ui/src/app/page.tsx +++ b/apps/ui/src/app/page.tsx @@ -1,5 +1,13 @@ import { redirect } from "next/navigation"; +import { + appendMarketingRedirectQuery, + type MarketingRedirectQuery, +} from "@/features/marketing/redirect-query"; -export default function HomePage() { - redirect("/project"); +export default async function HomePage({ + searchParams, +}: { + searchParams: Promise; +}) { + redirect(appendMarketingRedirectQuery("/project", await searchParams)); } diff --git a/apps/ui/src/features/analytics/brain-analytics-transport.test.ts b/apps/ui/src/features/analytics/brain-analytics-transport.test.ts new file mode 100644 index 00000000..c24178f5 --- /dev/null +++ b/apps/ui/src/features/analytics/brain-analytics-transport.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + configureBrainAnalyticsTransport, + sendBrainAnalyticsEvent, +} from "./brain-analytics-transport"; + +const payload = { + event: "module_view", + module: "brain", + context: "app", + view_name: "project_list", +}; + +test("iframe events wait for Desktop capability and relay exactly once", () => { + const previousWindow = globalThis.window; + const dataLayer: unknown[] = []; + const relayed: unknown[] = []; + Object.assign(globalThis, { window: { parent: {}, dataLayer } }); + configureBrainAnalyticsTransport(undefined); + try { + sendBrainAnalyticsEvent(payload); + assert.deepEqual(dataLayer, []); + assert.equal(relayed.length, 0); + configureBrainAnalyticsTransport((event) => { + relayed.push(event); + return Promise.resolve({ accepted: true }); + }); + assert.deepEqual(relayed, [payload]); + assert.deepEqual(dataLayer, []); + configureBrainAnalyticsTransport(null); + assert.deepEqual(dataLayer, []); + } finally { + configureBrainAnalyticsTransport(null); + Object.assign(globalThis, { window: previousWindow }); + } +}); + +test("older Desktop falls back to iframe GTM without losing queued events", () => { + const previousWindow = globalThis.window; + const dataLayer: unknown[] = []; + Object.assign(globalThis, { window: { parent: {}, dataLayer } }); + configureBrainAnalyticsTransport(undefined); + try { + sendBrainAnalyticsEvent(payload); + configureBrainAnalyticsTransport(null); + assert.deepEqual(dataLayer, [payload]); + } finally { + configureBrainAnalyticsTransport(null); + Object.assign(globalThis, { window: previousWindow }); + } +}); + +test("standalone Brain sends immediately without a Desktop bootstrap", () => { + const previousWindow = globalThis.window; + const browserWindow = { parent: null as unknown, dataLayer: [] as unknown[] }; + browserWindow.parent = browserWindow; + Object.assign(globalThis, { window: browserWindow }); + configureBrainAnalyticsTransport(undefined); + try { + sendBrainAnalyticsEvent(payload); + assert.deepEqual(browserWindow.dataLayer, [payload]); + } finally { + configureBrainAnalyticsTransport(null); + Object.assign(globalThis, { window: previousWindow }); + } +}); + +test("Desktop acknowledgement failures do not duplicate an event through local GTM", async () => { + const previousWindow = globalThis.window; + const previousWarn = console.warn; + const dataLayer: unknown[] = []; + let attempts = 0; + Object.assign(globalThis, { window: { parent: {}, dataLayer } }); + console.warn = () => undefined; + configureBrainAnalyticsTransport(() => { + attempts += 1; + return Promise.reject(new Error("reply lost after delivery")); + }); + try { + sendBrainAnalyticsEvent(payload); + await Promise.resolve(); + assert.equal(attempts, 1); + assert.deepEqual(dataLayer, []); + } finally { + configureBrainAnalyticsTransport(null); + console.warn = previousWarn; + Object.assign(globalThis, { window: previousWindow }); + } +}); diff --git a/apps/ui/src/features/analytics/brain-analytics-transport.ts b/apps/ui/src/features/analytics/brain-analytics-transport.ts new file mode 100644 index 00000000..54422c05 --- /dev/null +++ b/apps/ui/src/features/analytics/brain-analytics-transport.ts @@ -0,0 +1,45 @@ +"use client"; + +import { sendGTMEvent } from "@next/third-parties/google"; + +type AnalyticsPayload = Record; +type DesktopTransport = (payload: AnalyticsPayload) => Promise; + +let desktopTransport: DesktopTransport | null | undefined; +const pendingEvents: AnalyticsPayload[] = []; +const MAX_PENDING_EVENTS = 100; + +function dispatch(payload: AnalyticsPayload): void { + if (desktopTransport) { + // An acknowledged Desktop owns delivery; never also send through iframe GTM. + desktopTransport(payload).catch(() => { + console.warn("[Brain GTM] Desktop event delivery failed."); + }); + } else { + sendGTMEvent(payload); + } +} + +/** Resolve once the existing SDK reports the parent's analytics capability. */ +export function configureBrainAnalyticsTransport( + transport: DesktopTransport | null | undefined +): void { + desktopTransport = transport; + if (transport === undefined) { + return; + } + for (const payload of pendingEvents.splice(0)) { + dispatch(payload); + } +} + +export function sendBrainAnalyticsEvent(payload: AnalyticsPayload): void { + const embedded = window.parent && window.parent !== window; + if (embedded && desktopTransport === undefined) { + if (pendingEvents.length < MAX_PENDING_EVENTS) { + pendingEvents.push(payload); + } + return; + } + dispatch(payload); +} diff --git a/apps/ui/src/features/analytics/brain-gtm.ts b/apps/ui/src/features/analytics/brain-gtm.ts index 97fb40ce..772def6b 100644 --- a/apps/ui/src/features/analytics/brain-gtm.ts +++ b/apps/ui/src/features/analytics/brain-gtm.ts @@ -1,12 +1,12 @@ "use client"; -import { sendGTMEvent } from "@next/third-parties/google"; import type { CancellationReasonKey } from "@/features/billing/cancellation-survey/reasons"; import type { ProjectDrawerSurfaceEntry, ProjectMainSurfaceEntry, ProjectSideSurfaceEntry, } from "@/features/panes/surface-state"; +import { sendBrainAnalyticsEvent } from "./brain-analytics-transport"; export const BRAIN_GTM_MAX_PAYLOAD_BYTES = 2048; @@ -133,7 +133,7 @@ export function trackBrainGtmEvent(event: BrainGtmEvent): boolean { console.warn("[Brain GTM] Event payload exceeds the 2KB limit.", payload); return false; } - sendGTMEvent(payload); + sendBrainAnalyticsEvent(payload); return true; } catch (error) { try { diff --git a/apps/ui/src/features/marketing/redirect-query.test.ts b/apps/ui/src/features/marketing/redirect-query.test.ts new file mode 100644 index 00000000..7b632c66 --- /dev/null +++ b/apps/ui/src/features/marketing/redirect-query.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { appendMarketingRedirectQuery } from "./redirect-query"; + +test("root redirect retains encoded attribution and consent proof", () => { + const query = { sea_attr: "eyJ2ZXJzaW9uIjoyfQ", consent_token: "a+b/c=" }; + const url = new URL( + appendMarketingRedirectQuery("/project", query), + "https://brain.test" + ); + assert.equal(url.pathname, "/project"); + assert.equal(url.searchParams.get("sea_attr"), query.sea_attr); + assert.equal(url.searchParams.get("consent_token"), query.consent_token); +}); + +test("deployment redirect retains side intent and excludes credentials", () => { + const url = new URL( + appendMarketingRedirectQuery("/project?side=template#creation", { + sea_attr: "encoded", + consent_token: "proof", + token: "secret", + kubeconfig: "secret", + }), + "https://brain.test" + ); + assert.equal(url.searchParams.get("side"), "template"); + assert.equal(url.searchParams.get("sea_attr"), "encoded"); + assert.equal(url.searchParams.get("token"), null); + assert.equal(url.searchParams.get("kubeconfig"), null); + assert.equal(url.hash, "#creation"); +}); + +test("ambiguous and empty inbound parameters do not replace valid intent", () => { + assert.equal( + appendMarketingRedirectQuery("/project?side=github", { + sea_attr: ["first", "second"], + consent_token: " ", + }), + "/project?side=github" + ); +}); diff --git a/apps/ui/src/features/marketing/redirect-query.ts b/apps/ui/src/features/marketing/redirect-query.ts new file mode 100644 index 00000000..528c1f5c --- /dev/null +++ b/apps/ui/src/features/marketing/redirect-query.ts @@ -0,0 +1,19 @@ +export type MarketingRedirectQuery = Record< + string, + string | string[] | undefined +>; + +/** Carry attribution across server redirects without forwarding credentials. */ +export function appendMarketingRedirectQuery( + path: string, + query: MarketingRedirectQuery +): string { + const url = new URL(path, "https://brain.invalid"); + for (const key of ["sea_attr", "consent_token"]) { + const value = query[key]; + if (typeof value === "string" && value.trim() !== "") { + url.searchParams.set(key, value); + } + } + return `${url.pathname}${url.search}${url.hash}`; +} diff --git a/apps/ui/src/features/shell/auth-bootstrap.tsx b/apps/ui/src/features/shell/auth-bootstrap.tsx index e0848ec2..4abafcac 100644 --- a/apps/ui/src/features/shell/auth-bootstrap.tsx +++ b/apps/ui/src/features/shell/auth-bootstrap.tsx @@ -5,7 +5,9 @@ import { createSealosApp, sealosApp } from "@labring/sealos-desktop-sdk/app"; import { useAtomValue, useSetAtom } from "jotai"; import { useHydrateAtoms } from "jotai/utils"; import { useEffect } from "react"; +import { configureBrainAnalyticsTransport } from "@/features/analytics/brain-analytics-transport"; import { scheduleChatDevboxWarmup } from "@/features/chat/devbox/devbox.actions"; +import { readMarketingAttribution } from "@/features/marketing/attribution-client"; import { applySealosSdkHydration } from "@/features/shell/auth-bootstrap-core"; import { appTokenAtom, @@ -87,6 +89,29 @@ export function SealosSdkBootstrap() { useEffect(() => { let cancelled = false; const cleanup = createSealosApp(); + readMarketingAttribution(); + if (window.parent !== window) { + sealosApp + .getHostConfig() + .then((config) => { + if (cancelled) { + return; + } + const supported = + "analytics" in config.features && + config.features.analytics === true; + configureBrainAnalyticsTransport( + supported + ? (payload) => sealosApp.runEvents("trackAnalyticsEvent", payload) + : null + ); + }) + .catch(() => { + if (!cancelled) { + configureBrainAnalyticsTransport(null); + } + }); + } let unsubscribeLanguage: (() => void) | undefined; const hydrate = async () => {