mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
fix(core): event flow handle (#14256)
This commit is contained in:
3
.github/workflows/build-images.yml
vendored
3
.github/workflows/build-images.yml
vendored
@@ -46,6 +46,7 @@ jobs:
|
|||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
||||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||||
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
- name: Upload web artifact
|
- name: Upload web artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -79,6 +80,7 @@ jobs:
|
|||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
||||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||||
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
- name: Upload admin artifact
|
- name: Upload admin artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -112,6 +114,7 @@ jobs:
|
|||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
||||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||||
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
- name: Upload mobile artifact
|
- name: Upload mobile artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ jobs:
|
|||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
SENTRY_RELEASE: ${{ inputs.app_version }}
|
SENTRY_RELEASE: ${{ inputs.app_version }}
|
||||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||||
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
1
.github/workflows/release-desktop.yml
vendored
1
.github/workflows/release-desktop.yml
vendored
@@ -67,6 +67,7 @@ jobs:
|
|||||||
SENTRY_RELEASE: ${{ inputs.app-version }}
|
SENTRY_RELEASE: ${{ inputs.app-version }}
|
||||||
RELEASE_VERSION: ${{ inputs.app-version }}
|
RELEASE_VERSION: ${{ inputs.app-version }}
|
||||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||||
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
|
|
||||||
- name: Upload web artifact
|
- name: Upload web artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
2
.github/workflows/release-mobile.yml
vendored
2
.github/workflows/release-mobile.yml
vendored
@@ -40,6 +40,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PUBLIC_PATH: '/'
|
PUBLIC_PATH: '/'
|
||||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||||
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: 'affine'
|
SENTRY_PROJECT: 'affine'
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
@@ -69,6 +70,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PUBLIC_PATH: '/'
|
PUBLIC_PATH: '/'
|
||||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||||
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: 'affine'
|
SENTRY_PROJECT: 'affine'
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|||||||
@@ -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 { EditorHost } from '@blocksuite/affine/std';
|
||||||
import type { GfxPrimitiveElementModel } from '@blocksuite/affine/std/gfx';
|
import type { GfxPrimitiveElementModel } from '@blocksuite/affine/std/gfx';
|
||||||
import type { BlockModel } from '@blocksuite/affine/store';
|
import type { BlockModel } from '@blocksuite/affine/store';
|
||||||
@@ -73,7 +73,7 @@ const trackAction = ({
|
|||||||
eventName: AIActionEventName;
|
eventName: AIActionEventName;
|
||||||
properties: AIActionEventProperties;
|
properties: AIActionEventProperties;
|
||||||
}) => {
|
}) => {
|
||||||
mixpanel.track(eventName, properties);
|
tracker.track(eventName, properties);
|
||||||
};
|
};
|
||||||
|
|
||||||
const inferPageMode = (host: EditorHost) => {
|
const inferPageMode = (host: EditorHost) => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { AffineThemeViewExtension } from '@affine/core/blocksuite/view-extension
|
|||||||
import { TurboRendererViewExtension } from '@affine/core/blocksuite/view-extensions/turbo-renderer';
|
import { TurboRendererViewExtension } from '@affine/core/blocksuite/view-extensions/turbo-renderer';
|
||||||
import { PeekViewService } from '@affine/core/modules/peek-view';
|
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
import { mixpanel } from '@affine/track';
|
import { tracker } from '@affine/track';
|
||||||
import { DatabaseViewExtension } from '@blocksuite/affine/blocks/database/view';
|
import { DatabaseViewExtension } from '@blocksuite/affine/blocks/database/view';
|
||||||
import { ParagraphViewExtension } from '@blocksuite/affine/blocks/paragraph/view';
|
import { ParagraphViewExtension } from '@blocksuite/affine/blocks/paragraph/view';
|
||||||
import type {
|
import type {
|
||||||
@@ -162,7 +162,7 @@ class ViewProvider {
|
|||||||
this._manager.configure(FoundationViewExtension, {
|
this._manager.configure(FoundationViewExtension, {
|
||||||
telemetry: {
|
telemetry: {
|
||||||
track: (eventName, props) => {
|
track: (eventName, props) => {
|
||||||
mixpanel.track(eventName, props);
|
tracker.track(eventName, props);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontConfig: AffineCanvasTextFonts.map(font => ({
|
fontConfig: AffineCanvasTextFonts.map(font => ({
|
||||||
|
|||||||
@@ -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';
|
import { APP_SETTINGS_STORAGE_KEY } from '@toeverything/infra/atom';
|
||||||
|
|
||||||
mixpanel.init();
|
tracker.init();
|
||||||
sentry.init();
|
sentry.init();
|
||||||
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage !== 'undefined') {
|
||||||
@@ -18,5 +18,6 @@ if (typeof localStorage !== 'undefined') {
|
|||||||
// see: https://docs.mixpanel.com/docs/privacy/protecting-user-data
|
// see: https://docs.mixpanel.com/docs/privacy/protecting-user-data
|
||||||
// mixpanel.opt_out_tracking();
|
// mixpanel.opt_out_tracking();
|
||||||
sentry.disable();
|
sentry.disable();
|
||||||
|
ga4.setEnabled(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { enableAutoTrack, mixpanel, sentry } from '@affine/track';
|
import { enableAutoTrack, sentry, tracker } from '@affine/track';
|
||||||
import { appSettingAtom } from '@toeverything/infra';
|
import { appSettingAtom } from '@toeverything/infra';
|
||||||
import { useAtomValue } from 'jotai/react';
|
import { useAtomValue } from 'jotai/react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -9,12 +9,12 @@ export function Telemetry() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.enableTelemetry === false) {
|
if (settings.enableTelemetry === false) {
|
||||||
sentry.disable();
|
sentry.disable();
|
||||||
mixpanel.opt_out_tracking();
|
tracker.opt_out_tracking();
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
sentry.enable();
|
sentry.enable();
|
||||||
mixpanel.opt_in_tracking();
|
tracker.opt_in_tracking();
|
||||||
return enableAutoTrack(document.body, mixpanel.track);
|
return enableAutoTrack(document.body, tracker.track);
|
||||||
}
|
}
|
||||||
}, [settings.enableTelemetry]);
|
}, [settings.enableTelemetry]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type CreateCheckoutSessionInput } from '@affine/graphql';
|
import { type CreateCheckoutSessionInput } from '@affine/graphql';
|
||||||
import { mixpanel } from '@affine/track';
|
import { tracker } from '@affine/track';
|
||||||
import { OnEvent, Service } from '@toeverything/infra';
|
import { OnEvent, Service } from '@toeverything/infra';
|
||||||
|
|
||||||
import { Subscription } from '../entities/subscription';
|
import { Subscription } from '../entities/subscription';
|
||||||
@@ -18,7 +18,7 @@ export class SubscriptionService extends Service {
|
|||||||
.map(sub => !!sub)
|
.map(sub => !!sub)
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.subscribe(ai => {
|
.subscribe(ai => {
|
||||||
mixpanel.people.set({
|
tracker.people.set({
|
||||||
ai,
|
ai,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -26,7 +26,7 @@ export class SubscriptionService extends Service {
|
|||||||
.map(sub => !!sub)
|
.map(sub => !!sub)
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.subscribe(pro => {
|
.subscribe(pro => {
|
||||||
mixpanel.people.set({
|
tracker.people.set({
|
||||||
pro,
|
pro,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mixpanel } from '@affine/track';
|
import { tracker } from '@affine/track';
|
||||||
import { OnEvent, Service } from '@toeverything/infra';
|
import { OnEvent, Service } from '@toeverything/infra';
|
||||||
|
|
||||||
import { UserQuota } from '../entities/user-quota';
|
import { UserQuota } from '../entities/user-quota';
|
||||||
@@ -13,7 +13,7 @@ export class UserQuotaService extends Service {
|
|||||||
.map(q => q?.humanReadable.name)
|
.map(q => q?.humanReadable.name)
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.subscribe(quota => {
|
.subscribe(quota => {
|
||||||
mixpanel.people.set({
|
tracker.people.set({
|
||||||
quota,
|
quota,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { shallowEqual } from '@affine/component';
|
import { shallowEqual } from '@affine/component';
|
||||||
import { ServerDeploymentType } from '@affine/graphql';
|
import { ServerDeploymentType } from '@affine/graphql';
|
||||||
import { mixpanel } from '@affine/track';
|
import { tracker } from '@affine/track';
|
||||||
import { LiveData, OnEvent, Service } from '@toeverything/infra';
|
import { LiveData, OnEvent, Service } from '@toeverything/infra';
|
||||||
|
|
||||||
import type { AuthAccountInfo, Server, ServersService } from '../../cloud';
|
import type { AuthAccountInfo, Server, ServersService } from '../../cloud';
|
||||||
@@ -47,19 +47,19 @@ export class TelemetryService extends Service {
|
|||||||
const unsubscribe = this.currentAccount$.subscribe(
|
const unsubscribe = this.currentAccount$.subscribe(
|
||||||
({ account, selfHosted }) => {
|
({ account, selfHosted }) => {
|
||||||
if (prevAccount) {
|
if (prevAccount) {
|
||||||
mixpanel.reset();
|
tracker.reset();
|
||||||
}
|
}
|
||||||
// the isSelfHosted property from environment is not reliable
|
// the isSelfHosted property from environment is not reliable
|
||||||
if (selfHosted !== prevSelfHosted) {
|
if (selfHosted !== prevSelfHosted) {
|
||||||
mixpanel.register({
|
tracker.register({
|
||||||
isSelfHosted: selfHosted,
|
isSelfHosted: selfHosted,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
prevSelfHosted = selfHosted;
|
prevSelfHosted = selfHosted;
|
||||||
prevAccount = account ?? null;
|
prevAccount = account ?? null;
|
||||||
if (account) {
|
if (account) {
|
||||||
mixpanel.identify(account.id);
|
tracker.identify(account.id);
|
||||||
mixpanel.people.set({
|
tracker.people.set({
|
||||||
$email: account.email,
|
$email: account.email,
|
||||||
$name: account.label,
|
$name: account.label,
|
||||||
$avatar: account.avatar,
|
$avatar: account.avatar,
|
||||||
@@ -78,7 +78,7 @@ export class TelemetryService extends Service {
|
|||||||
|
|
||||||
registerMiddlewares() {
|
registerMiddlewares() {
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
mixpanel.middleware((_event, parameters) => {
|
tracker.middleware((_event, parameters) => {
|
||||||
const extraContext = this.extractGlobalContext();
|
const extraContext = this.extractGlobalContext();
|
||||||
return {
|
return {
|
||||||
...extraContext,
|
...extraContext,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mixpanel } from '@affine/track';
|
import { tracker } from '@affine/track';
|
||||||
import { createEvent, Service } from '@toeverything/infra';
|
import { createEvent, Service } from '@toeverything/infra';
|
||||||
import { combineLatest, distinctUntilChanged, map, skip } from 'rxjs';
|
import { combineLatest, distinctUntilChanged, map, skip } from 'rxjs';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export class WorkbenchService extends Service {
|
|||||||
)
|
)
|
||||||
.subscribe(newLocation => {
|
.subscribe(newLocation => {
|
||||||
this.eventBus.root.emit(WorkbenchLocationChanged, newLocation);
|
this.eventBus.root.emit(WorkbenchLocationChanged, newLocation);
|
||||||
mixpanel.track_pageview({
|
tracker.track_pageview({
|
||||||
location: newLocation,
|
location: newLocation,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function enableAutoTrack(root: HTMLElement, trackFn: TrackFn) {
|
|||||||
if (dataset['eventProps']) {
|
if (dataset['eventProps']) {
|
||||||
const args: Record<string, any> = {};
|
const args: Record<string, any> = {};
|
||||||
if (dataset['eventArg'] !== undefined) {
|
if (dataset['eventArg'] !== undefined) {
|
||||||
args['arg'] = dataset['event-arg'];
|
args['arg'] = dataset['eventArg'];
|
||||||
} else {
|
} else {
|
||||||
for (const argName of Object.keys(dataset)) {
|
for (const argName of Object.keys(dataset)) {
|
||||||
if (argName.startsWith('eventArgs')) {
|
if (argName.startsWith('eventArgs')) {
|
||||||
@@ -115,7 +115,7 @@ export function enableAutoTrack(root: HTMLElement, trackFn: TrackFn) {
|
|||||||
|
|
||||||
declare module 'react' {
|
declare module 'react' {
|
||||||
// we have to declare `T` but it's actually not used
|
// we have to declare `T` but it's actually not used
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
interface HTMLAttributes<T> {
|
interface HTMLAttributes<T> {
|
||||||
'data-event-props'?: EventsUnion;
|
'data-event-props'?: EventsUnion;
|
||||||
'data-event-arg'?: string;
|
'data-event-arg'?: string;
|
||||||
|
|||||||
333
packages/frontend/track/src/ga4.ts
Normal file
333
packages/frontend/track/src/ga4.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
track_pageview: 'page_view',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARAM_RENAME_MAP = new Map<string, string>([
|
||||||
|
['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<string, string>([
|
||||||
|
['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<string, unknown> {
|
||||||
|
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<string, unknown>,
|
||||||
|
prefix = ''
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>,
|
||||||
|
maxParams = 25
|
||||||
|
): Record<string, Scalar> {
|
||||||
|
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<string, Scalar> = {};
|
||||||
|
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<string, unknown>
|
||||||
|
): Record<string, string> {
|
||||||
|
const sanitized: Record<string, string> = {};
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { enableAutoTrack, makeTracker } from './auto';
|
import { enableAutoTrack, makeTracker } from './auto';
|
||||||
import { type EventArgs, type Events } from './events';
|
import { type EventArgs, type Events } from './events';
|
||||||
import { mixpanel } from './mixpanel';
|
import { ga4 } from './ga4';
|
||||||
|
import { tracker } from './mixpanel';
|
||||||
import { sentry } from './sentry';
|
import { sentry } from './sentry';
|
||||||
export const track = makeTracker((event, props) => {
|
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;
|
export default track;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { DebugLogger } from '@affine/debug';
|
|||||||
import type { Dict, OverridedMixpanel } from 'mixpanel-browser';
|
import type { Dict, OverridedMixpanel } from 'mixpanel-browser';
|
||||||
import mixpanelBrowser from 'mixpanel-browser';
|
import mixpanelBrowser from 'mixpanel-browser';
|
||||||
|
|
||||||
|
import { ga4 } from './ga4';
|
||||||
|
|
||||||
const logger = new DebugLogger('mixpanel');
|
const logger = new DebugLogger('mixpanel');
|
||||||
|
|
||||||
type Middleware = (
|
type Middleware = (
|
||||||
@@ -43,9 +45,11 @@ function createMixpanel() {
|
|||||||
register(props: Dict) {
|
register(props: Dict) {
|
||||||
logger.debug('register with', props);
|
logger.debug('register with', props);
|
||||||
mixpanel.register(props);
|
mixpanel.register(props);
|
||||||
|
ga4.setUserProperties(props);
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
mixpanel.reset();
|
mixpanel.reset();
|
||||||
|
ga4.reset();
|
||||||
this.init();
|
this.init();
|
||||||
},
|
},
|
||||||
track(event_name: string, properties?: Record<string, any>) {
|
track(event_name: string, properties?: Record<string, any>) {
|
||||||
@@ -58,6 +62,7 @@ function createMixpanel() {
|
|||||||
logger.debug('track', event_name, middlewareProperties);
|
logger.debug('track', event_name, middlewareProperties);
|
||||||
|
|
||||||
mixpanel.track(event_name as string, middlewareProperties);
|
mixpanel.track(event_name as string, middlewareProperties);
|
||||||
|
ga4.track(event_name as string, middlewareProperties);
|
||||||
},
|
},
|
||||||
middleware(cb: Middleware): () => void {
|
middleware(cb: Middleware): () => void {
|
||||||
middlewares.add(cb);
|
middlewares.add(cb);
|
||||||
@@ -67,9 +72,11 @@ function createMixpanel() {
|
|||||||
},
|
},
|
||||||
opt_out_tracking() {
|
opt_out_tracking() {
|
||||||
mixpanel.opt_out_tracking();
|
mixpanel.opt_out_tracking();
|
||||||
|
ga4.setEnabled(false);
|
||||||
},
|
},
|
||||||
opt_in_tracking() {
|
opt_in_tracking() {
|
||||||
mixpanel.opt_in_tracking();
|
mixpanel.opt_in_tracking();
|
||||||
|
ga4.setEnabled(true);
|
||||||
},
|
},
|
||||||
has_opted_in_tracking() {
|
has_opted_in_tracking() {
|
||||||
mixpanel.has_opted_in_tracking();
|
mixpanel.has_opted_in_tracking();
|
||||||
@@ -79,9 +86,16 @@ function createMixpanel() {
|
|||||||
},
|
},
|
||||||
identify(unique_id?: string) {
|
identify(unique_id?: string) {
|
||||||
mixpanel.identify(unique_id);
|
mixpanel.identify(unique_id);
|
||||||
|
ga4.setUserId(unique_id);
|
||||||
},
|
},
|
||||||
get people() {
|
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 }) {
|
track_pageview(properties?: { location?: string }) {
|
||||||
const middlewareProperties = Array.from(middlewares).reduce(
|
const middlewareProperties = Array.from(middlewares).reduce(
|
||||||
@@ -92,13 +106,14 @@ function createMixpanel() {
|
|||||||
);
|
);
|
||||||
logger.debug('track_pageview', middlewareProperties);
|
logger.debug('track_pageview', middlewareProperties);
|
||||||
mixpanel.track_pageview(middlewareProperties);
|
mixpanel.track_pageview(middlewareProperties);
|
||||||
|
ga4.pageview(middlewareProperties);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return wrapped;
|
return wrapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mixpanel = createMixpanel();
|
export const tracker = createMixpanel();
|
||||||
|
|
||||||
function createProxyHandler() {
|
function createProxyHandler() {
|
||||||
const handler = {
|
const handler = {
|
||||||
|
|||||||
1
tools/@types/build-config/__all.d.ts
vendored
1
tools/@types/build-config/__all.d.ts
vendored
@@ -38,6 +38,7 @@ declare interface BUILD_CONFIG_TYPE {
|
|||||||
CAPTCHA_SITE_KEY: string;
|
CAPTCHA_SITE_KEY: string;
|
||||||
SENTRY_DSN: string;
|
SENTRY_DSN: string;
|
||||||
MIXPANEL_TOKEN: string;
|
MIXPANEL_TOKEN: string;
|
||||||
|
GA4_MEASUREMENT_ID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare var BUILD_CONFIG: BUILD_CONFIG_TYPE;
|
declare var BUILD_CONFIG: BUILD_CONFIG_TYPE;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function getBuildConfig(
|
|||||||
CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '',
|
CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '',
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
||||||
MIXPANEL_TOKEN: process.env.MIXPANEL_TOKEN ?? '',
|
MIXPANEL_TOKEN: process.env.MIXPANEL_TOKEN ?? '',
|
||||||
|
GA4_MEASUREMENT_ID: process.env.GA4_MEASUREMENT_ID ?? '',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
get beta() {
|
get beta() {
|
||||||
|
|||||||
Reference in New Issue
Block a user