Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { API_NAME } from '../../../../../packages/client-sdk/src/constants';
import { createMasterAPP, masterApp } from '../../../../../packages/client-sdk/src/master';

afterEach(() => vi.unstubAllGlobals());

describe('SDK event sender context', () => {
it('passes the browser supplied source and origin to the relay and acknowledges once', async () => {
const listeners = new Map<string, (event: unknown) => void>();
const source = { postMessage: vi.fn() };
const origin = 'https://brain.usw-1.sealos.io';
vi.stubGlobal('window', {
location: { origin: 'https://usw-1.sealos.io' },
addEventListener: (name: string, listener: (event: unknown) => void) =>
listeners.set(name, listener),
removeEventListener: (name: string) => listeners.delete(name)
});
const cleanup = createMasterAPP({
allowedOrigins: [origin],
getWorkspaceQuotaApi: async () => []
});
const handler = vi.fn(() => ({ accepted: true }));
masterApp.addEventListen('trackAnalyticsEvent', handler);
const payload = { event: 'deployment_create', module: 'brain', source: 'forged' };
try {
listeners.get('message')?.({
origin,
source,
data: {
apiName: API_NAME.EVENT_BUS,
messageId: 'event-1',
data: { eventName: 'trackAnalyticsEvent', eventData: payload }
}
});
await Promise.resolve();
expect(handler).toHaveBeenCalledExactlyOnceWith(payload, { source, origin });
expect(source.postMessage).toHaveBeenCalledExactlyOnceWith(
{
masterOrigin: 'https://usw-1.sealos.io',
messageId: 'event-1',
success: true,
message: '',
data: { accepted: true }
},
{ targetOrigin: origin }
);
listeners.get('message')?.({
origin: 'https://untrusted.example',
source,
data: {
apiName: API_NAME.EVENT_BUS,
messageId: 'event-2',
data: { eventName: 'trackAnalyticsEvent', eventData: payload }
}
});
expect(handler).toHaveBeenCalledTimes(1);
} finally {
cleanup?.();
}
});
});
174 changes: 174 additions & 0 deletions frontend/desktop/src/__tests__/unit/utils/brain-analytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, expect, it, vi } from 'vitest';
import {
brainAnalyticsAttribution,
createBrainAnalyticsRelay,
isBrainAnalyticsSender
} from '@/utils/brain-analytics';

function setup(gtmEnabled = true) {
const pushGtm = vi.fn();
const client = { event: vi.fn(), identify: vi.fn(), getUserId: () => null };
const relay = createBrainAnalyticsRelay({
gtmEnabled: () => gtmEnabled,
userId: () => 'desktop-uuid',
properties: () => ({ product_user_id: 'desktop-uuid', first_source: 'google' }),
rybbit: () => client,
pushGtm
});
return { relay, pushGtm, client };
}

describe('Brain analytics relay', () => {
it('forwards deployment creation through parent GTM exactly once', () => {
const { relay, pushGtm, client } = setup();
expect(
relay({
event: 'deployment_create',
module: 'brain',
method: 'template',
config: { template_name: 'dify' }
})
).toEqual({ accepted: true });
expect(pushGtm).toHaveBeenCalledExactlyOnceWith({
event: 'deployment_create',
context: 'app',
module: 'brain',
method: 'template',
config: { template_name: 'dify' }
});
expect(client.event).not.toHaveBeenCalled();
});

it('sends onboarding through Rybbit with the Desktop UUID and attribution', () => {
const { relay, pushGtm, client } = setup();
relay({ event: 'onboarding_step_view', module: 'brain', step: 2 });
expect(client.identify).toHaveBeenCalledExactlyOnceWith('desktop-uuid');
expect(client.event).toHaveBeenCalledExactlyOnceWith('onboarding_step_view', {
context: 'app',
module: 'brain',
surface: 'product',
step: 2,
product_user_id: 'desktop-uuid',
first_source: 'google'
});
expect(pushGtm).not.toHaveBeenCalled();
});

it('uses direct Rybbit when GTM is disabled', () => {
const { relay, pushGtm, client } = setup(false);
relay({ event: 'deployment_create', module: 'brain', method: 'docker' });
expect(client.event).toHaveBeenCalledTimes(1);
expect(pushGtm).not.toHaveBeenCalled();
});

it('does not accept unrelated events or copy credentials and feedback text', () => {
const { relay, pushGtm, client } = setup();
expect(relay({ event: 'login_success', module: 'brain' })).toEqual({ accepted: false });
expect(relay({ event: 'module_view', module: 'other' })).toEqual({ accepted: false });
relay({
event: 'subscription_cancel',
module: 'brain',
plan_name: 'Pro',
has_feedback: true,
reasons: ['cost'],
feedback: 'private text',
token: 'secret'
});
expect(client.event).toHaveBeenCalledExactlyOnceWith('subscription_cancel', {
context: 'app',
module: 'brain',
surface: 'product',
product_user_id: 'desktop-uuid',
first_source: 'google',
plan_name: 'Pro',
has_feedback: 'true',
reasons: '["cost"]'
});
expect(pushGtm).not.toHaveBeenCalled();
});

it('buffers direct events while the Rybbit script loads', () => {
vi.useFakeTimers();
const client = { event: vi.fn(), identify: vi.fn() };
const rybbit = vi.fn().mockReturnValueOnce(undefined).mockReturnValue(client);
vi.stubGlobal('window', { setTimeout });
try {
const relay = createBrainAnalyticsRelay({
gtmEnabled: () => true,
userId: () => 'desktop-uuid',
properties: () => ({}),
rybbit,
pushGtm: vi.fn()
});
relay({ event: 'onboarding_complete', module: 'brain' });
expect(client.event).not.toHaveBeenCalled();
vi.runAllTimers();
expect(client.event).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
vi.unstubAllGlobals();
}
});

it('keeps direct properties within the Rybbit byte budget', () => {
const event = vi.fn();
const relay = createBrainAnalyticsRelay({
gtmEnabled: () => true,
userId: () => 'desktop-uuid',
properties: () => ({
product_user_id: 'desktop-uuid',
first_campaign: '部署'.repeat(400),
last_campaign: '部署'.repeat(400)
}),
rybbit: () => ({ event, identify: vi.fn() }),
pushGtm: vi.fn()
});
relay({
event: 'subscription_cancel',
module: 'brain',
plan_name: 'Pro',
has_feedback: true,
reasons: ['cost']
});
const props = event.mock.calls[0][1];
expect(new TextEncoder().encode(JSON.stringify(props)).byteLength).toBeLessThan(1900);
expect(props.product_user_id).toBe('desktop-uuid');
expect(props.plan_name).toBe('Pro');
});

it('rejects a different iframe or a mismatched origin', () => {
const frame = {} as Window;
const sender = { source: frame, origin: 'https://brain.usw-1.sealos.io' };
expect(isBrainAnalyticsSender(sender, frame, 'https://brain.usw-1.sealos.io/project')).toBe(
true
);
expect(isBrainAnalyticsSender(sender, {} as Window, 'https://brain.usw-1.sealos.io')).toBe(
false
);
expect(
isBrainAnalyticsSender(
{ ...sender, origin: 'https://other.sealos.io' },
frame,
'https://brain.usw-1.sealos.io'
)
).toBe(false);
expect(isBrainAnalyticsSender(sender, frame, 'not a URL')).toBe(false);
});

it('reads attribution without copying consent proof or click identifiers', () => {
const storage = {
getItem: () =>
JSON.stringify({
version: 2,
consent_token: 'proof',
first_touch: { source: 'google', click_id_value: 'private-click', campaign: 'launch' }
})
};
expect(brainAnalyticsAttribution(storage)).toEqual({
attr_version: '2',
first_source: 'google',
first_campaign: 'launch'
});
expect(brainAnalyticsAttribution({ getItem: () => '{broken' })).toEqual({});
});
});
53 changes: 52 additions & 1 deletion frontend/desktop/src/components/desktop_content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ import { useAppDisplayConfigStore } from '@/stores/appDisplayConfig';
import { useGuideModalStore } from '@/stores/guideModal';
import GuideModal from '../account/GuideModal';
import { GlobalNotification } from './GlobalNotification';
import {
brainAnalyticsAttribution,
createBrainAnalyticsRelay,
isBrainAnalyticsSender
} from '@/utils/brain-analytics';

const AppDock = dynamic(() => import('../AppDock'), { ssr: false });
const FloatButton = dynamic(() => import('@/components/floating_button'), { ssr: false });
Expand Down Expand Up @@ -212,12 +217,58 @@ export default function Desktop({ initialAppLaunch, onInitialAppLoaded }: Deskto
regionUid: config.cloudConfig?.regionUID || ''
},
features: {
subscription: config.layoutConfig?.common?.subscriptionEnabled || false
subscription: config.layoutConfig?.common?.subscriptionEnabled || false,
analytics: Boolean(
config.layoutConfig?.gtmId ||
(config.layoutConfig?.rybbitHost && config.layoutConfig?.rybbitSiteId)
)
}
};
}
});
const relayBrainAnalytics = createBrainAnalyticsRelay({
gtmEnabled: () => Boolean(useConfigStore.getState().layoutConfig?.gtmId),
userId: () => useSessionStore.getState().session?.user?.userUid,
properties: () => {
const activeSession = useSessionStore.getState().session;
return {
...brainAnalyticsAttribution(window.localStorage),
product_user_id: activeSession?.user?.userUid || '',
product_user_numeric_id: activeSession?.user?.userId || '',
workspace: activeSession?.user?.nsid || '',
workspace_plan: activeSession?.subscription?.PlanName || ''
};
},
rybbit: () => {
const analyticsWindow = window as typeof window & {
sealos_product?: {
event: (name: string, properties: Record<string, string | number>) => void;
identify: (id: string) => void;
getUserId?: () => string | null;
};
};
return analyticsWindow.sealos_product || window.rybbit;
},
pushGtm: (event) => {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(event);
}
});
const trackAnalyticsEvent = (
payload: unknown,
sender?: { source: MessageEventSource; origin: string }
) => {
const brainApp = useAppStore.getState().runningInfo.find((app) => app.key === BRAIN_APP_KEY);
const iframe = document.getElementById(
`app-window-${BRAIN_APP_KEY}`
) as HTMLIFrameElement | null;
if (!isBrainAnalyticsSender(sender, iframe?.contentWindow, brainApp?.data.url)) {
return { accepted: false };
}
return relayBrainAnalytics(payload);
};
const cleanups = [
masterApp?.addEventListen('trackAnalyticsEvent', trackAnalyticsEvent),
masterApp?.addEventListen('openDesktopApp', openDesktopApp),
masterApp?.addEventListen('closeDesktopApp', closeDesktopApp),
masterApp?.addEventListen('requestLogin', handleRequestLogin),
Expand Down
Loading
Loading