From 13907f7234989ef07407c444b654f44d20618f8b Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:04:32 +0800 Subject: [PATCH] fix(core): event flow handle (#14256) --- .github/workflows/build-images.yml | 3 + .../workflows/release-desktop-platform.yml | 1 + .github/workflows/release-desktop.yml | 1 + .github/workflows/release-mobile.yml | 2 + .../src/blocksuite/ai/provider/tracker.ts | 4 +- .../core/src/blocksuite/manager/view.ts | 4 +- .../frontend/core/src/bootstrap/telemetry.ts | 5 +- .../core/src/components/telemetry/index.tsx | 8 +- .../modules/cloud/services/subscription.ts | 6 +- .../src/modules/cloud/services/user-quota.ts | 4 +- .../modules/telemetry/services/telemetry.ts | 12 +- .../modules/workbench/services/workbench.ts | 4 +- packages/frontend/track/src/auto.ts | 4 +- packages/frontend/track/src/ga4.ts | 333 ++++++++++++++++++ packages/frontend/track/src/index.ts | 7 +- packages/frontend/track/src/mixpanel.ts | 19 +- tools/@types/build-config/__all.d.ts | 1 + tools/utils/src/build-config.ts | 1 + 18 files changed, 389 insertions(+), 30 deletions(-) create mode 100644 packages/frontend/track/src/ga4.ts diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index 1df80bcd6..1d0973ad3 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -46,6 +46,7 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} + GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} - name: Upload web artifact uses: actions/upload-artifact@v4 with: @@ -79,6 +80,7 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} + GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} - name: Upload admin artifact uses: actions/upload-artifact@v4 with: @@ -112,6 +114,7 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} + GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} - name: Upload mobile artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release-desktop-platform.yml b/.github/workflows/release-desktop-platform.yml index 94fea60e7..358d55e17 100644 --- a/.github/workflows/release-desktop-platform.yml +++ b/.github/workflows/release-desktop-platform.yml @@ -69,6 +69,7 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_RELEASE: ${{ inputs.app_version }} MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} + GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index e89634b36..6a34fd491 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -67,6 +67,7 @@ jobs: SENTRY_RELEASE: ${{ inputs.app-version }} RELEASE_VERSION: ${{ inputs.app-version }} MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} + GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} - name: Upload web artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml index e88e95499..07198780b 100644 --- a/.github/workflows/release-mobile.yml +++ b/.github/workflows/release-mobile.yml @@ -40,6 +40,7 @@ jobs: env: PUBLIC_PATH: '/' MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} + GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: 'affine' SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -69,6 +70,7 @@ jobs: env: PUBLIC_PATH: '/' MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} + GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: 'affine' SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/packages/frontend/core/src/blocksuite/ai/provider/tracker.ts b/packages/frontend/core/src/blocksuite/ai/provider/tracker.ts index 5d5530139..cf087768d 100644 --- a/packages/frontend/core/src/blocksuite/ai/provider/tracker.ts +++ b/packages/frontend/core/src/blocksuite/ai/provider/tracker.ts @@ -1,4 +1,4 @@ -import { mixpanel, track } from '@affine/track'; +import { track, tracker } from '@affine/track'; import type { EditorHost } from '@blocksuite/affine/std'; import type { GfxPrimitiveElementModel } from '@blocksuite/affine/std/gfx'; import type { BlockModel } from '@blocksuite/affine/store'; @@ -73,7 +73,7 @@ const trackAction = ({ eventName: AIActionEventName; properties: AIActionEventProperties; }) => { - mixpanel.track(eventName, properties); + tracker.track(eventName, properties); }; const inferPageMode = (host: EditorHost) => { diff --git a/packages/frontend/core/src/blocksuite/manager/view.ts b/packages/frontend/core/src/blocksuite/manager/view.ts index d75580534..3e5d19199 100644 --- a/packages/frontend/core/src/blocksuite/manager/view.ts +++ b/packages/frontend/core/src/blocksuite/manager/view.ts @@ -24,7 +24,7 @@ import { AffineThemeViewExtension } from '@affine/core/blocksuite/view-extension import { TurboRendererViewExtension } from '@affine/core/blocksuite/view-extensions/turbo-renderer'; import { PeekViewService } from '@affine/core/modules/peek-view'; import { DebugLogger } from '@affine/debug'; -import { mixpanel } from '@affine/track'; +import { tracker } from '@affine/track'; import { DatabaseViewExtension } from '@blocksuite/affine/blocks/database/view'; import { ParagraphViewExtension } from '@blocksuite/affine/blocks/paragraph/view'; import type { @@ -162,7 +162,7 @@ class ViewProvider { this._manager.configure(FoundationViewExtension, { telemetry: { track: (eventName, props) => { - mixpanel.track(eventName, props); + tracker.track(eventName, props); }, }, fontConfig: AffineCanvasTextFonts.map(font => ({ diff --git a/packages/frontend/core/src/bootstrap/telemetry.ts b/packages/frontend/core/src/bootstrap/telemetry.ts index f32182f9a..788ec7e96 100644 --- a/packages/frontend/core/src/bootstrap/telemetry.ts +++ b/packages/frontend/core/src/bootstrap/telemetry.ts @@ -1,7 +1,7 @@ -import { mixpanel, sentry } from '@affine/track'; +import { ga4, sentry, tracker } from '@affine/track'; import { APP_SETTINGS_STORAGE_KEY } from '@toeverything/infra/atom'; -mixpanel.init(); +tracker.init(); sentry.init(); if (typeof localStorage !== 'undefined') { @@ -18,5 +18,6 @@ if (typeof localStorage !== 'undefined') { // see: https://docs.mixpanel.com/docs/privacy/protecting-user-data // mixpanel.opt_out_tracking(); sentry.disable(); + ga4.setEnabled(false); } } diff --git a/packages/frontend/core/src/components/telemetry/index.tsx b/packages/frontend/core/src/components/telemetry/index.tsx index 3f4d29a00..59e149487 100644 --- a/packages/frontend/core/src/components/telemetry/index.tsx +++ b/packages/frontend/core/src/components/telemetry/index.tsx @@ -1,4 +1,4 @@ -import { enableAutoTrack, mixpanel, sentry } from '@affine/track'; +import { enableAutoTrack, sentry, tracker } from '@affine/track'; import { appSettingAtom } from '@toeverything/infra'; import { useAtomValue } from 'jotai/react'; import { useEffect } from 'react'; @@ -9,12 +9,12 @@ export function Telemetry() { useEffect(() => { if (settings.enableTelemetry === false) { sentry.disable(); - mixpanel.opt_out_tracking(); + tracker.opt_out_tracking(); return; } else { sentry.enable(); - mixpanel.opt_in_tracking(); - return enableAutoTrack(document.body, mixpanel.track); + tracker.opt_in_tracking(); + return enableAutoTrack(document.body, tracker.track); } }, [settings.enableTelemetry]); diff --git a/packages/frontend/core/src/modules/cloud/services/subscription.ts b/packages/frontend/core/src/modules/cloud/services/subscription.ts index eb79d3da4..130dfe38f 100644 --- a/packages/frontend/core/src/modules/cloud/services/subscription.ts +++ b/packages/frontend/core/src/modules/cloud/services/subscription.ts @@ -1,5 +1,5 @@ import { type CreateCheckoutSessionInput } from '@affine/graphql'; -import { mixpanel } from '@affine/track'; +import { tracker } from '@affine/track'; import { OnEvent, Service } from '@toeverything/infra'; import { Subscription } from '../entities/subscription'; @@ -18,7 +18,7 @@ export class SubscriptionService extends Service { .map(sub => !!sub) .distinctUntilChanged() .subscribe(ai => { - mixpanel.people.set({ + tracker.people.set({ ai, }); }); @@ -26,7 +26,7 @@ export class SubscriptionService extends Service { .map(sub => !!sub) .distinctUntilChanged() .subscribe(pro => { - mixpanel.people.set({ + tracker.people.set({ pro, }); }); diff --git a/packages/frontend/core/src/modules/cloud/services/user-quota.ts b/packages/frontend/core/src/modules/cloud/services/user-quota.ts index 1c6bca80a..9e001b4d4 100644 --- a/packages/frontend/core/src/modules/cloud/services/user-quota.ts +++ b/packages/frontend/core/src/modules/cloud/services/user-quota.ts @@ -1,4 +1,4 @@ -import { mixpanel } from '@affine/track'; +import { tracker } from '@affine/track'; import { OnEvent, Service } from '@toeverything/infra'; import { UserQuota } from '../entities/user-quota'; @@ -13,7 +13,7 @@ export class UserQuotaService extends Service { .map(q => q?.humanReadable.name) .distinctUntilChanged() .subscribe(quota => { - mixpanel.people.set({ + tracker.people.set({ quota, }); }); diff --git a/packages/frontend/core/src/modules/telemetry/services/telemetry.ts b/packages/frontend/core/src/modules/telemetry/services/telemetry.ts index 18cf240c6..638f44e3c 100644 --- a/packages/frontend/core/src/modules/telemetry/services/telemetry.ts +++ b/packages/frontend/core/src/modules/telemetry/services/telemetry.ts @@ -1,6 +1,6 @@ import { shallowEqual } from '@affine/component'; import { ServerDeploymentType } from '@affine/graphql'; -import { mixpanel } from '@affine/track'; +import { tracker } from '@affine/track'; import { LiveData, OnEvent, Service } from '@toeverything/infra'; import type { AuthAccountInfo, Server, ServersService } from '../../cloud'; @@ -47,19 +47,19 @@ export class TelemetryService extends Service { const unsubscribe = this.currentAccount$.subscribe( ({ account, selfHosted }) => { if (prevAccount) { - mixpanel.reset(); + tracker.reset(); } // the isSelfHosted property from environment is not reliable if (selfHosted !== prevSelfHosted) { - mixpanel.register({ + tracker.register({ isSelfHosted: selfHosted, }); } prevSelfHosted = selfHosted; prevAccount = account ?? null; if (account) { - mixpanel.identify(account.id); - mixpanel.people.set({ + tracker.identify(account.id); + tracker.people.set({ $email: account.email, $name: account.label, $avatar: account.avatar, @@ -78,7 +78,7 @@ export class TelemetryService extends Service { registerMiddlewares() { this.disposables.push( - mixpanel.middleware((_event, parameters) => { + tracker.middleware((_event, parameters) => { const extraContext = this.extractGlobalContext(); return { ...extraContext, diff --git a/packages/frontend/core/src/modules/workbench/services/workbench.ts b/packages/frontend/core/src/modules/workbench/services/workbench.ts index bf6d3fe37..0a538089a 100644 --- a/packages/frontend/core/src/modules/workbench/services/workbench.ts +++ b/packages/frontend/core/src/modules/workbench/services/workbench.ts @@ -1,4 +1,4 @@ -import { mixpanel } from '@affine/track'; +import { tracker } from '@affine/track'; import { createEvent, Service } from '@toeverything/infra'; import { combineLatest, distinctUntilChanged, map, skip } from 'rxjs'; @@ -19,7 +19,7 @@ export class WorkbenchService extends Service { ) .subscribe(newLocation => { this.eventBus.root.emit(WorkbenchLocationChanged, newLocation); - mixpanel.track_pageview({ + tracker.track_pageview({ location: newLocation, }); }); diff --git a/packages/frontend/track/src/auto.ts b/packages/frontend/track/src/auto.ts index 24894f543..6f2a56bca 100644 --- a/packages/frontend/track/src/auto.ts +++ b/packages/frontend/track/src/auto.ts @@ -74,7 +74,7 @@ export function enableAutoTrack(root: HTMLElement, trackFn: TrackFn) { if (dataset['eventProps']) { const args: Record = {}; if (dataset['eventArg'] !== undefined) { - args['arg'] = dataset['event-arg']; + args['arg'] = dataset['eventArg']; } else { for (const argName of Object.keys(dataset)) { if (argName.startsWith('eventArgs')) { @@ -115,7 +115,7 @@ export function enableAutoTrack(root: HTMLElement, trackFn: TrackFn) { declare module 'react' { // we have to declare `T` but it's actually not used - // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface HTMLAttributes { 'data-event-props'?: EventsUnion; 'data-event-arg'?: string; diff --git a/packages/frontend/track/src/ga4.ts b/packages/frontend/track/src/ga4.ts new file mode 100644 index 000000000..34ae48ced --- /dev/null +++ b/packages/frontend/track/src/ga4.ts @@ -0,0 +1,333 @@ +type Scalar = string | number; + +declare global { + interface Window { + gtag?: (...args: any[]) => void; + dataLayer?: unknown[]; + } +} + +const EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,39}$/; +const PARAM_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,39}$/; +const USER_PROP_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,23}$/; +const GA4_MEASUREMENT_ID = BUILD_CONFIG.GA4_MEASUREMENT_ID; +const GTAG_SCRIPT_ID = 'ga4-gtag'; + +const EVENT_NAME_ALIAS: Record = { + track_pageview: 'page_view', +}; + +const PARAM_RENAME_MAP = new Map([ + ['page', 'ui_page'], + ['segment', 'ui_segment'], + ['module', 'ui_module'], + ['arg', 'ui_arg'], + ['control', 'ui_control'], + ['option', 'ui_option'], + ['key', 'setting_key'], + ['value', 'setting_value'], + ['docId', 'doc_id'], + ['workspaceId', 'workspace_id'], + ['serverId', 'server_id'], + ['docType', 'doc_type'], + ['docCount', 'doc_count'], + ['unreadCount', 'unread_count'], + ['withAttachment', 'with_attachment'], + ['withMention', 'with_mention'], + ['appName', 'app_name'], + ['recurring', 'billing_cycle'], + ['plan', 'plan_name'], + ['time', 'duration_ms'], + ['error', 'error_code'], + ['status', 'result'], + ['success', 'result'], + ['to', 'target'], + ['on', 'enabled'], +]); + +const USER_PROP_RENAME_MAP = new Map([ + ['appVersion', 'app_version'], + ['editorVersion', 'editor_version'], + ['environment', 'environment'], + ['isDesktop', 'is_desktop'], + ['distribution', 'distribution'], + ['isSelfHosted', 'is_self_hosted'], + ['ai', 'ai_enabled'], + ['pro', 'plan_tier'], + ['quota', 'quota_tier'], +]); + +const DROP_PARAM_SEGMENTS = new Set(['other', 'instruction', 'operation']); +const DROP_MAPPED_PARAMS = new Set(['doc_id', 'workspace_id', 'server_id']); + +const PRIORITY_KEYS = [ + 'ui_page', + 'ui_segment', + 'ui_module', + 'ui_control', + 'ui_option', + 'ui_arg', + 'type', + 'method', + 'mode', + 'plan_name', + 'billing_cycle', + 'role', + 'result', + 'error_code', + 'category', + 'doc_type', + 'item', + 'action', + 'target', + 'enabled', + 'setting_key', + 'setting_value', + 'duration_ms', + 'doc_count', + 'unread_count', + 'with_attachment', + 'with_mention', +]; + +let enabled = true; +let configured = false; + +function ensureGtagLoaded(): boolean { + if (!enabled || !GA4_MEASUREMENT_ID) return false; + if (typeof window === 'undefined' || typeof document === 'undefined') { + return false; + } + + if (!window.gtag) { + window.dataLayer = window.dataLayer || []; + window.gtag = (...args: any[]) => { + window.dataLayer?.push(args); + }; + } + + if (!document.getElementById(GTAG_SCRIPT_ID)) { + const script = document.createElement('script'); + script.id = GTAG_SCRIPT_ID; + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent( + GA4_MEASUREMENT_ID + )}`; + (document.head || document.body || document.documentElement).appendChild( + script + ); + } + + if (!configured) { + configured = true; + window.gtag('js', new Date()); + window.gtag('config', GA4_MEASUREMENT_ID, { send_page_view: false }); + } + + return true; +} + +function toSnakeCase(input: string): string { + return input + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, '$1_$2') + .replace(/\s+/g, '_') + .replace(/[^a-zA-Z0-9_]/g, '_') + .replace(/__+/g, '_') + .toLowerCase(); +} + +function toScalar(v: unknown): Scalar | undefined { + if (v === null || v === undefined) return undefined; + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'boolean') return v ? 1 : 0; + if (typeof v === 'string') return v.length > 100 ? v.slice(0, 100) : v; + if (v instanceof Date) return v.toISOString(); + + try { + const s = JSON.stringify(v); + return s.length > 100 ? s.slice(0, 100) : s; + } catch { + return undefined; + } +} + +function normalizeValue(key: string, value: unknown): Scalar | undefined { + if (key === 'result' && typeof value === 'boolean') { + return value ? 'success' : 'failure'; + } + if (key === 'enabled' && typeof value === 'boolean') { + return value ? 'on' : 'off'; + } + return toScalar(value); +} + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object') return false; + if (value instanceof Date) return false; + if (Array.isArray(value)) return false; + return Object.getPrototypeOf(value) === Object.prototype; +} + +function flattenProps( + input: Record, + prefix = '' +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(input)) { + const path = prefix ? `${prefix}.${key}` : key; + if (isPlainObject(value)) { + const nested = flattenProps(value, path); + Object.assign(out, nested); + } else { + out[path] = value; + } + } + return out; +} + +function mapParamKey(path: string): string { + const segments = path.split('.'); + const mappedSegments = segments.map( + segment => PARAM_RENAME_MAP.get(segment) ?? segment + ); + return toSnakeCase(mappedSegments.join('_')); +} + +function mapEventName(name: string): string { + const alias = EVENT_NAME_ALIAS[name]; + return toSnakeCase(alias ?? name); +} + +function shouldDropPath(path: string): boolean { + const segments = path.split('.'); + return segments.some(segment => DROP_PARAM_SEGMENTS.has(segment)); +} + +function sanitizeParams( + input: Record, + maxParams = 25 +): Record { + const flattened = flattenProps(input); + const mappedEntries: Array<[string, Scalar]> = []; + + for (const [path, value] of Object.entries(flattened)) { + if (shouldDropPath(path)) continue; + + const mappedKey = mapParamKey(path); + if (!mappedKey || !PARAM_NAME_RE.test(mappedKey)) continue; + if (DROP_MAPPED_PARAMS.has(mappedKey)) continue; + + const normalized = normalizeValue(mappedKey, value); + if (normalized === undefined) continue; + + mappedEntries.push([mappedKey, normalized]); + } + + const prioritySet = new Set(PRIORITY_KEYS); + mappedEntries.sort((a, b) => { + const aPriority = prioritySet.has(a[0]); + const bPriority = prioritySet.has(b[0]); + if (aPriority === bPriority) return 0; + return aPriority ? -1 : 1; + }); + + const out: Record = {}; + for (const [key, value] of mappedEntries) { + if (Object.keys(out).length >= maxParams) break; + if (key in out) continue; + out[key] = value; + } + return out; +} + +function mapUserPropKey(key: string): string | undefined { + if (key.startsWith('$')) return undefined; + const mapped = USER_PROP_RENAME_MAP.get(key) ?? key; + return toSnakeCase(mapped); +} + +function sanitizeUserProperties( + props: Record +): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(props)) { + const mappedKey = mapUserPropKey(key); + if (!mappedKey || !USER_PROP_NAME_RE.test(mappedKey)) continue; + + let mappedValue = value; + if (key === 'pro' && typeof value === 'boolean') { + mappedValue = value ? 'pro' : 'free'; + } + + const scalar = toScalar(mappedValue); + if (scalar === undefined) continue; + + const stringValue = String(scalar); + sanitized[mappedKey] = + stringValue.length > 36 ? stringValue.slice(0, 36) : stringValue; + } + return sanitized; +} + +export const ga4 = { + setEnabled(v: boolean) { + enabled = v; + if (enabled) { + ensureGtagLoaded(); + } + }, + + reset() { + if (!ensureGtagLoaded()) return; + window.gtag?.('set', 'user_id', undefined); + window.gtag?.('set', 'user_properties', {}); + }, + + setUserId(userId?: string) { + if (!ensureGtagLoaded()) return; + window.gtag?.('set', 'user_id', userId ? String(userId) : undefined); + }, + + setUserProperties(props: Record) { + if (!ensureGtagLoaded()) return; + const sanitized = sanitizeUserProperties(props); + if (Object.keys(sanitized).length === 0) return; + window.gtag?.('set', 'user_properties', sanitized); + }, + + track(eventName: string, props: Record = {}) { + if (!ensureGtagLoaded()) return; + const mappedEvent = mapEventName(eventName); + if (!EVENT_NAME_RE.test(mappedEvent)) return; + + const sanitized = sanitizeParams(props); + window.gtag?.('event', mappedEvent, sanitized); + }, + + pageview(props: Record = {}) { + if (!ensureGtagLoaded()) return; + const pageLocation = + typeof props.location === 'string' ? props.location : location.href; + let pagePath = location.pathname + location.search; + if (typeof pageLocation === 'string') { + try { + const url = new URL(pageLocation, location.origin); + pagePath = url.pathname + url.search; + } catch { + pagePath = location.pathname + location.search; + } + } + + const customParams = { ...props }; + delete customParams.location; + + const sanitized = sanitizeParams(customParams, 22); + window.gtag?.('event', 'page_view', { + page_location: pageLocation, + page_path: pagePath, + page_title: document.title, + ...sanitized, + }); + }, +}; diff --git a/packages/frontend/track/src/index.ts b/packages/frontend/track/src/index.ts index e9ecbbfc4..2aa7c085e 100644 --- a/packages/frontend/track/src/index.ts +++ b/packages/frontend/track/src/index.ts @@ -1,10 +1,11 @@ import { enableAutoTrack, makeTracker } from './auto'; import { type EventArgs, type Events } from './events'; -import { mixpanel } from './mixpanel'; +import { ga4 } from './ga4'; +import { tracker } from './mixpanel'; import { sentry } from './sentry'; export const track = makeTracker((event, props) => { - mixpanel.track(event, props); + tracker.track(event, props); }); -export { enableAutoTrack, type EventArgs, type Events, mixpanel, sentry }; +export { enableAutoTrack, type EventArgs, type Events, ga4, sentry, tracker }; export default track; diff --git a/packages/frontend/track/src/mixpanel.ts b/packages/frontend/track/src/mixpanel.ts index c6458b93e..974a03aac 100644 --- a/packages/frontend/track/src/mixpanel.ts +++ b/packages/frontend/track/src/mixpanel.ts @@ -2,6 +2,8 @@ import { DebugLogger } from '@affine/debug'; import type { Dict, OverridedMixpanel } from 'mixpanel-browser'; import mixpanelBrowser from 'mixpanel-browser'; +import { ga4 } from './ga4'; + const logger = new DebugLogger('mixpanel'); type Middleware = ( @@ -43,9 +45,11 @@ function createMixpanel() { register(props: Dict) { logger.debug('register with', props); mixpanel.register(props); + ga4.setUserProperties(props); }, reset() { mixpanel.reset(); + ga4.reset(); this.init(); }, track(event_name: string, properties?: Record) { @@ -58,6 +62,7 @@ function createMixpanel() { logger.debug('track', event_name, middlewareProperties); mixpanel.track(event_name as string, middlewareProperties); + ga4.track(event_name as string, middlewareProperties); }, middleware(cb: Middleware): () => void { middlewares.add(cb); @@ -67,9 +72,11 @@ function createMixpanel() { }, opt_out_tracking() { mixpanel.opt_out_tracking(); + ga4.setEnabled(false); }, opt_in_tracking() { mixpanel.opt_in_tracking(); + ga4.setEnabled(true); }, has_opted_in_tracking() { mixpanel.has_opted_in_tracking(); @@ -79,9 +86,16 @@ function createMixpanel() { }, identify(unique_id?: string) { mixpanel.identify(unique_id); + ga4.setUserId(unique_id); }, get people() { - return mixpanel.people; + const people = mixpanel.people; + return { + set: (props: Dict) => { + people?.set?.(props); + ga4.setUserProperties(props); + }, + } as typeof mixpanel.people; }, track_pageview(properties?: { location?: string }) { const middlewareProperties = Array.from(middlewares).reduce( @@ -92,13 +106,14 @@ function createMixpanel() { ); logger.debug('track_pageview', middlewareProperties); mixpanel.track_pageview(middlewareProperties); + ga4.pageview(middlewareProperties); }, }; return wrapped; } -export const mixpanel = createMixpanel(); +export const tracker = createMixpanel(); function createProxyHandler() { const handler = { diff --git a/tools/@types/build-config/__all.d.ts b/tools/@types/build-config/__all.d.ts index 58da669ad..c03f816dd 100644 --- a/tools/@types/build-config/__all.d.ts +++ b/tools/@types/build-config/__all.d.ts @@ -38,6 +38,7 @@ declare interface BUILD_CONFIG_TYPE { CAPTCHA_SITE_KEY: string; SENTRY_DSN: string; MIXPANEL_TOKEN: string; + GA4_MEASUREMENT_ID: string; } declare var BUILD_CONFIG: BUILD_CONFIG_TYPE; diff --git a/tools/utils/src/build-config.ts b/tools/utils/src/build-config.ts index 1ee80698e..ab4f9ffa6 100644 --- a/tools/utils/src/build-config.ts +++ b/tools/utils/src/build-config.ts @@ -54,6 +54,7 @@ export function getBuildConfig( CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '', SENTRY_DSN: process.env.SENTRY_DSN ?? '', MIXPANEL_TOKEN: process.env.MIXPANEL_TOKEN ?? '', + GA4_MEASUREMENT_ID: process.env.GA4_MEASUREMENT_ID ?? '', }; }, get beta() {