chore: improve event flow (#14266)
This commit is contained in:
@@ -245,6 +245,34 @@
|
||||
"env": "DOC_SERVICE_ENDPOINT"
|
||||
}
|
||||
},
|
||||
"telemetry": {
|
||||
"allowedOrigin": {
|
||||
"type": "Array",
|
||||
"desc": "Allowed origins for telemetry collection."
|
||||
},
|
||||
"ga4.measurementId": {
|
||||
"type": "String",
|
||||
"desc": "GA4 Measurement ID for Measurement Protocol.",
|
||||
"env": "GA4_MEASUREMENT_ID"
|
||||
},
|
||||
"ga4.apiSecret": {
|
||||
"type": "String",
|
||||
"desc": "GA4 API secret for Measurement Protocol.",
|
||||
"env": "GA4_API_SECRET"
|
||||
},
|
||||
"dedupe.ttlHours": {
|
||||
"type": "Number",
|
||||
"desc": "Telemetry dedupe TTL in hours."
|
||||
},
|
||||
"dedupe.maxEntries": {
|
||||
"type": "Number",
|
||||
"desc": "Telemetry dedupe max entries."
|
||||
},
|
||||
"batch.maxEvents": {
|
||||
"type": "Number",
|
||||
"desc": "Max events per telemetry batch."
|
||||
}
|
||||
},
|
||||
"client": {
|
||||
"versionControl.enabled": {
|
||||
"type": "Boolean",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/nbstore": "workspace:*",
|
||||
"@affine/track": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@capacitor/android": "^7.0.0",
|
||||
|
||||
@@ -31,6 +31,7 @@ import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspac
|
||||
import { getWorkerUrl } from '@affine/env/worker';
|
||||
import { I18n } from '@affine/i18n';
|
||||
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
||||
import { setTelemetryTransport } from '@affine/track';
|
||||
import { Container } from '@blocksuite/affine/global/di';
|
||||
import {
|
||||
docLinkBaseURLMiddleware,
|
||||
@@ -56,6 +57,7 @@ import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||
import { writeEndpointToken } from './proxy';
|
||||
|
||||
const storeManagerClient = createStoreManagerClient();
|
||||
setTelemetryTransport(storeManagerClient.telemetry);
|
||||
window.addEventListener('beforeunload', () => {
|
||||
storeManagerClient.dispose();
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{ "path": "../../../common/env" },
|
||||
{ "path": "../../i18n" },
|
||||
{ "path": "../../../common/nbstore" },
|
||||
{ "path": "../../track" },
|
||||
{ "path": "../../../../blocksuite/affine/all" },
|
||||
{ "path": "../../../common/infra" }
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NbstoreProvider } from '@affine/core/modules/storage';
|
||||
import { apis } from '@affine/electron-api';
|
||||
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
||||
import { setTelemetryTransport } from '@affine/track';
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
import { OpClient } from '@toeverything/infra/op';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -43,6 +44,7 @@ function createStoreManagerClient() {
|
||||
|
||||
export function setupStoreManager(framework: Framework) {
|
||||
const storeManagerClient = createStoreManagerClient();
|
||||
setTelemetryTransport(storeManagerClient.telemetry);
|
||||
window.addEventListener('beforeunload', () => {
|
||||
storeManagerClient.dispose();
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/nbstore": "workspace:*",
|
||||
"@affine/track": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@capacitor/app": "^7.0.0",
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from '@affine/graphql';
|
||||
import { I18n } from '@affine/i18n';
|
||||
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
||||
import { setTelemetryTransport } from '@affine/track';
|
||||
import { Container } from '@blocksuite/affine/global/di';
|
||||
import {
|
||||
docLinkBaseURLMiddleware,
|
||||
@@ -74,6 +75,7 @@ import { writeEndpointToken } from './proxy';
|
||||
import { enableNavigationGesture$ } from './web-navigation-control';
|
||||
|
||||
const storeManagerClient = createStoreManagerClient();
|
||||
setTelemetryTransport(storeManagerClient.telemetry);
|
||||
window.addEventListener('beforeunload', () => {
|
||||
storeManagerClient.dispose();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
{ "path": "../../../common/graphql" },
|
||||
{ "path": "../../i18n" },
|
||||
{ "path": "../../../common/nbstore" },
|
||||
{ "path": "../../track" },
|
||||
{ "path": "../../../../blocksuite/affine/all" },
|
||||
{ "path": "../../../common/infra" },
|
||||
{ "path": "../../../../tools/cli" },
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/nbstore": "workspace:*",
|
||||
"@affine/track": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@sentry/react": "^9.47.1",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench'
|
||||
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
|
||||
import { getWorkerUrl } from '@affine/env/worker';
|
||||
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
||||
import { setTelemetryTransport } from '@affine/track';
|
||||
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
|
||||
import { OpClient } from '@toeverything/infra/op';
|
||||
import { Suspense } from 'react';
|
||||
@@ -31,6 +32,7 @@ if (window.SharedWorker) {
|
||||
const worker = new Worker(workerUrl);
|
||||
storeManagerClient = new StoreManagerClient(new OpClient(worker));
|
||||
}
|
||||
setTelemetryTransport(storeManagerClient.telemetry);
|
||||
window.addEventListener('beforeunload', () => {
|
||||
storeManagerClient.dispose();
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{ "path": "../../../common/env" },
|
||||
{ "path": "../../i18n" },
|
||||
{ "path": "../../../common/nbstore" },
|
||||
{ "path": "../../track" },
|
||||
{ "path": "../../../../blocksuite/affine/all" },
|
||||
{ "path": "../../../common/infra" }
|
||||
]
|
||||
|
||||
@@ -14,6 +14,7 @@ import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspac
|
||||
import createEmotionCache from '@affine/core/utils/create-emotion-cache';
|
||||
import { getWorkerUrl } from '@affine/env/worker';
|
||||
import { StoreManagerClient } from '@affine/nbstore/worker/client';
|
||||
import { setTelemetryTransport } from '@affine/track';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
|
||||
import { OpClient } from '@toeverything/infra/op';
|
||||
@@ -38,6 +39,7 @@ if (
|
||||
const worker = new Worker(workerUrl);
|
||||
storeManagerClient = new StoreManagerClient(new OpClient(worker));
|
||||
}
|
||||
setTelemetryTransport(storeManagerClient.telemetry);
|
||||
window.addEventListener('beforeunload', () => {
|
||||
storeManagerClient.dispose();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ga4, sentry, tracker } from '@affine/track';
|
||||
import { sentry, tracker } from '@affine/track';
|
||||
import { APP_SETTINGS_STORAGE_KEY } from '@toeverything/infra/atom';
|
||||
|
||||
tracker.init();
|
||||
@@ -14,10 +14,8 @@ if (typeof localStorage !== 'undefined') {
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
// NOTE(@forehalo): mixpanel will read local storage flag and doesn't need to be manually opt_out at startup time.
|
||||
// see: https://docs.mixpanel.com/docs/privacy/protecting-user-data
|
||||
// mixpanel.opt_out_tracking();
|
||||
// NOTE: telemetry setting is respected by tracker and sentry.
|
||||
sentry.disable();
|
||||
ga4.setEnabled(false);
|
||||
tracker.opt_out_tracking();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,3 +187,32 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
export type TelemetryChannel =
|
||||
| 'stable'
|
||||
| 'beta'
|
||||
| 'internal'
|
||||
| 'canary'
|
||||
| 'local';
|
||||
|
||||
const OFFICIAL_TELEMETRY_ENDPOINTS: Record<TelemetryChannel, string> = {
|
||||
stable: 'https://app.affine.pro',
|
||||
beta: 'https://insider.affine.pro',
|
||||
internal: 'https://insider.affine.pro',
|
||||
canary: 'https://affine.fail',
|
||||
local: 'http://localhost:8080',
|
||||
};
|
||||
|
||||
export function getOfficialTelemetryEndpoint(
|
||||
channel = BUILD_CONFIG.appBuildType
|
||||
): string {
|
||||
if (BUILD_CONFIG.debug) {
|
||||
return BUILD_CONFIG.isNative
|
||||
? OFFICIAL_TELEMETRY_ENDPOINTS.local
|
||||
: location.origin;
|
||||
} else if (['beta', 'internal', 'canary', 'stable'].includes(channel)) {
|
||||
return OFFICIAL_TELEMETRY_ENDPOINTS[channel];
|
||||
}
|
||||
|
||||
return OFFICIAL_TELEMETRY_ENDPOINTS.stable;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { shallowEqual } from '@affine/component';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { ServerDeploymentType } from '@affine/graphql';
|
||||
import { tracker } from '@affine/track';
|
||||
import { flushTelemetry, setTelemetryContext, tracker } from '@affine/track';
|
||||
import { LiveData, OnEvent, Service } from '@toeverything/infra';
|
||||
|
||||
import type { AuthAccountInfo, Server, ServersService } from '../../cloud';
|
||||
import { getOfficialTelemetryEndpoint } from '../../cloud/constant';
|
||||
import type { GlobalContextService } from '../../global-context';
|
||||
import { ApplicationStarted } from '../../lifecycle';
|
||||
|
||||
const logger = new DebugLogger('telemetry-service');
|
||||
|
||||
@OnEvent(ApplicationStarted, e => e.onApplicationStart)
|
||||
export class TelemetryService extends Service {
|
||||
private readonly disposableFns: (() => void)[] = [];
|
||||
@@ -46,6 +50,21 @@ export class TelemetryService extends Service {
|
||||
let prevSelfHosted: boolean | undefined = undefined;
|
||||
const unsubscribe = this.currentAccount$.subscribe(
|
||||
({ account, selfHosted }) => {
|
||||
const channel =
|
||||
BUILD_CONFIG.appBuildType === 'beta' ||
|
||||
BUILD_CONFIG.appBuildType === 'internal' ||
|
||||
BUILD_CONFIG.appBuildType === 'canary' ||
|
||||
BUILD_CONFIG.appBuildType === 'stable'
|
||||
? BUILD_CONFIG.appBuildType
|
||||
: 'stable';
|
||||
|
||||
setTelemetryContext({
|
||||
isAuthed: !!account,
|
||||
isSelfHosted: !!selfHosted,
|
||||
channel,
|
||||
officialEndpoint: getOfficialTelemetryEndpoint(channel),
|
||||
});
|
||||
|
||||
if (prevAccount) {
|
||||
tracker.reset();
|
||||
}
|
||||
@@ -64,6 +83,13 @@ export class TelemetryService extends Service {
|
||||
$name: account.label,
|
||||
$avatar: account.avatar,
|
||||
});
|
||||
void flushTelemetry().catch(error => {
|
||||
logger.error('failed to flush telemetry after login', error);
|
||||
});
|
||||
} else if (prevAccount) {
|
||||
void flushTelemetry().catch(error => {
|
||||
logger.error('failed to flush telemetry after logout', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -9,11 +9,10 @@
|
||||
"dependencies": {
|
||||
"@affine/debug": "workspace:*",
|
||||
"@sentry/react": "^9.47.1",
|
||||
"mixpanel-browser": "^2.56.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"react-router-dom": "^6.30.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mixpanel-browser": "^2.50.2",
|
||||
"@types/react": "^19.0.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
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,11 +1,24 @@
|
||||
import { enableAutoTrack, makeTracker } from './auto';
|
||||
import { type EventArgs, type Events } from './events';
|
||||
import { ga4 } from './ga4';
|
||||
import { tracker } from './mixpanel';
|
||||
import { sentry } from './sentry';
|
||||
import {
|
||||
flushTelemetry,
|
||||
setTelemetryContext,
|
||||
setTelemetryTransport,
|
||||
} from './telemetry';
|
||||
import { tracker } from './tracker';
|
||||
export const track = makeTracker((event, props) => {
|
||||
tracker.track(event, props);
|
||||
});
|
||||
|
||||
export { enableAutoTrack, type EventArgs, type Events, ga4, sentry, tracker };
|
||||
export {
|
||||
enableAutoTrack,
|
||||
type EventArgs,
|
||||
type Events,
|
||||
flushTelemetry,
|
||||
sentry,
|
||||
setTelemetryContext,
|
||||
setTelemetryTransport,
|
||||
tracker,
|
||||
};
|
||||
export default track;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
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 = (
|
||||
name: string,
|
||||
properties?: Record<string, unknown>
|
||||
) => Record<string, unknown>;
|
||||
|
||||
function createMixpanel() {
|
||||
let mixpanel;
|
||||
if (BUILD_CONFIG.MIXPANEL_TOKEN) {
|
||||
mixpanelBrowser.init(BUILD_CONFIG.MIXPANEL_TOKEN || '', {
|
||||
track_pageview: true,
|
||||
persistence: 'localStorage',
|
||||
api_host: 'https://telemetry.affine.run',
|
||||
ignore_dnt: true,
|
||||
});
|
||||
mixpanel = mixpanelBrowser;
|
||||
} else {
|
||||
mixpanel = new Proxy(
|
||||
function () {} as unknown as OverridedMixpanel,
|
||||
createProxyHandler()
|
||||
);
|
||||
}
|
||||
|
||||
const middlewares = new Set<Middleware>();
|
||||
|
||||
const wrapped = {
|
||||
init() {
|
||||
const defaultProps = {
|
||||
appVersion: BUILD_CONFIG.appVersion,
|
||||
environment: BUILD_CONFIG.appBuildType,
|
||||
editorVersion: BUILD_CONFIG.editorVersion,
|
||||
isDesktop: BUILD_CONFIG.isElectron,
|
||||
distribution: BUILD_CONFIG.distribution,
|
||||
};
|
||||
this.register(defaultProps);
|
||||
},
|
||||
// provide a way to override the default properties
|
||||
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<string, any>) {
|
||||
const middlewareProperties = Array.from(middlewares).reduce(
|
||||
(acc, middleware) => {
|
||||
return middleware(event_name, acc);
|
||||
},
|
||||
properties as Record<string, unknown>
|
||||
);
|
||||
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);
|
||||
return () => {
|
||||
middlewares.delete(cb);
|
||||
};
|
||||
},
|
||||
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();
|
||||
},
|
||||
has_opted_out_tracking() {
|
||||
mixpanel.has_opted_out_tracking();
|
||||
},
|
||||
identify(unique_id?: string) {
|
||||
mixpanel.identify(unique_id);
|
||||
ga4.setUserId(unique_id);
|
||||
},
|
||||
get 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(
|
||||
(acc, middleware) => {
|
||||
return middleware('track_pageview', acc);
|
||||
},
|
||||
properties as Record<string, unknown>
|
||||
);
|
||||
logger.debug('track_pageview', middlewareProperties);
|
||||
mixpanel.track_pageview(middlewareProperties);
|
||||
ga4.pageview(middlewareProperties);
|
||||
},
|
||||
};
|
||||
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export const tracker = createMixpanel();
|
||||
|
||||
function createProxyHandler() {
|
||||
const handler = {
|
||||
get: () => {
|
||||
return new Proxy(
|
||||
function () {} as unknown as OverridedMixpanel,
|
||||
createProxyHandler()
|
||||
);
|
||||
},
|
||||
apply: () => {},
|
||||
} as ProxyHandler<OverridedMixpanel>;
|
||||
return handler;
|
||||
}
|
||||
155
packages/frontend/track/src/telemetry.ts
Normal file
155
packages/frontend/track/src/telemetry.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
|
||||
export type TelemetryEvent = {
|
||||
schemaVersion: 1;
|
||||
eventName: string;
|
||||
params?: Record<string, unknown>;
|
||||
userProperties?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
clientId: string;
|
||||
sessionId?: string;
|
||||
eventId: string;
|
||||
timestampMicros?: number;
|
||||
context?: {
|
||||
appVersion?: string;
|
||||
editorVersion?: string;
|
||||
environment?: string;
|
||||
distribution?: string;
|
||||
channel?: 'stable' | 'beta' | 'internal' | 'canary';
|
||||
isDesktop?: boolean;
|
||||
isMobile?: boolean;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
url?: string;
|
||||
referrer?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TelemetryContext = {
|
||||
isAuthed: boolean;
|
||||
isSelfHosted: boolean;
|
||||
channel: 'stable' | 'beta' | 'internal' | 'canary';
|
||||
userId?: string;
|
||||
userProperties?: Record<string, unknown>;
|
||||
officialEndpoint: string;
|
||||
};
|
||||
|
||||
export type TelemetryAck =
|
||||
| { ok: true; accepted: number; dropped: number }
|
||||
| { ok: false; error: { name: string; message: string } };
|
||||
|
||||
export type TelemetryTransport = {
|
||||
setContext: (context: TelemetryContext) => Promise<void> | void;
|
||||
track: (event: TelemetryEvent) => Promise<{ queued: boolean }> | void;
|
||||
pageview?: (event: TelemetryEvent) => Promise<{ queued: boolean }> | void;
|
||||
flush?: () => Promise<TelemetryAck> | void;
|
||||
};
|
||||
|
||||
type TelemetryContextUpdate = Partial<TelemetryContext> & {
|
||||
userProperties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type TelemetryContextUpdateOptions = {
|
||||
replaceUserProperties?: boolean;
|
||||
};
|
||||
|
||||
const logger = new DebugLogger('telemetry');
|
||||
const pendingEvents: TelemetryEvent[] = [];
|
||||
const PENDING_LIMIT = 500;
|
||||
|
||||
const defaultChannel =
|
||||
BUILD_CONFIG.appBuildType === 'beta' ||
|
||||
BUILD_CONFIG.appBuildType === 'internal' ||
|
||||
BUILD_CONFIG.appBuildType === 'canary' ||
|
||||
BUILD_CONFIG.appBuildType === 'stable'
|
||||
? BUILD_CONFIG.appBuildType
|
||||
: 'stable';
|
||||
|
||||
let context: TelemetryContext = {
|
||||
isAuthed: false,
|
||||
isSelfHosted: false,
|
||||
channel: defaultChannel,
|
||||
officialEndpoint: '',
|
||||
userProperties: {},
|
||||
};
|
||||
|
||||
let transport: TelemetryTransport | null = null;
|
||||
|
||||
export function setTelemetryTransport(next: TelemetryTransport | null) {
|
||||
transport = next;
|
||||
if (!transport) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyTransportContext(context);
|
||||
flushPending().catch(error => {
|
||||
logger.error('failed to flush pending telemetry events', error);
|
||||
});
|
||||
}
|
||||
|
||||
export function setTelemetryContext(
|
||||
update: TelemetryContextUpdate,
|
||||
options: TelemetryContextUpdateOptions = {}
|
||||
) {
|
||||
const nextUserProps = options.replaceUserProperties
|
||||
? (update.userProperties ?? {})
|
||||
: {
|
||||
...(context.userProperties ?? {}),
|
||||
...(update.userProperties ?? {}),
|
||||
};
|
||||
|
||||
context = {
|
||||
...context,
|
||||
...update,
|
||||
userProperties: nextUserProps,
|
||||
};
|
||||
|
||||
applyTransportContext(context);
|
||||
}
|
||||
|
||||
export function getTelemetryContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function sendTelemetryEvent(event: TelemetryEvent) {
|
||||
if (!transport) {
|
||||
enqueuePending(event);
|
||||
return { queued: true };
|
||||
}
|
||||
|
||||
return await transport.track(event);
|
||||
}
|
||||
|
||||
export async function flushTelemetry() {
|
||||
if (!transport?.flush) {
|
||||
return { ok: true, accepted: 0, dropped: 0 } as const;
|
||||
}
|
||||
return await transport.flush();
|
||||
}
|
||||
|
||||
async function flushPending() {
|
||||
if (!transport || pendingEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = pendingEvents.splice(0, pendingEvents.length);
|
||||
for (const event of events) {
|
||||
await transport.track(event);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueuePending(event: TelemetryEvent) {
|
||||
if (pendingEvents.length >= PENDING_LIMIT) {
|
||||
pendingEvents.shift();
|
||||
}
|
||||
pendingEvents.push(event);
|
||||
}
|
||||
|
||||
function applyTransportContext(next: TelemetryContext) {
|
||||
if (!transport) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(transport.setContext(next)).catch(error => {
|
||||
logger.error('failed to set telemetry context', error);
|
||||
});
|
||||
}
|
||||
260
packages/frontend/track/src/tracker.ts
Normal file
260
packages/frontend/track/src/tracker.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import type { TelemetryEvent } from './telemetry';
|
||||
import { sendTelemetryEvent, setTelemetryContext } from './telemetry';
|
||||
|
||||
const logger = new DebugLogger('telemetry');
|
||||
|
||||
type TrackProperties = Record<string, unknown> | undefined;
|
||||
type RawTrackProperties = Record<string, unknown> | object | undefined;
|
||||
|
||||
type Middleware = (
|
||||
name: string,
|
||||
properties?: TrackProperties
|
||||
) => Record<string, unknown>;
|
||||
|
||||
const CLIENT_ID_KEY = 'affine_telemetry_client_id';
|
||||
const SESSION_ID_KEY = 'affine_telemetry_session_id';
|
||||
|
||||
let enabled = true;
|
||||
let clientId = readPersistentId(CLIENT_ID_KEY, localStorageSafe());
|
||||
let sessionId = readPersistentId(SESSION_ID_KEY, sessionStorageSafe());
|
||||
|
||||
let userId: string | undefined;
|
||||
let userProperties: Record<string, unknown> = {};
|
||||
const middlewares = new Set<Middleware>();
|
||||
|
||||
export const tracker = {
|
||||
init() {
|
||||
this.register({
|
||||
appVersion: BUILD_CONFIG.appVersion,
|
||||
environment: BUILD_CONFIG.appBuildType,
|
||||
editorVersion: BUILD_CONFIG.editorVersion,
|
||||
isDesktop: BUILD_CONFIG.isElectron,
|
||||
isMobile: BUILD_CONFIG.isMobileEdition,
|
||||
distribution: BUILD_CONFIG.distribution,
|
||||
});
|
||||
},
|
||||
|
||||
register(props: Record<string, unknown>) {
|
||||
userProperties = {
|
||||
...userProperties,
|
||||
...props,
|
||||
};
|
||||
setTelemetryContext({ userProperties });
|
||||
},
|
||||
|
||||
reset() {
|
||||
userId = undefined;
|
||||
userProperties = {};
|
||||
sessionId = readPersistentId(SESSION_ID_KEY, sessionStorageSafe(), true);
|
||||
setTelemetryContext(
|
||||
{ userId, userProperties },
|
||||
{ replaceUserProperties: true }
|
||||
);
|
||||
this.init();
|
||||
},
|
||||
|
||||
track(eventName: string, properties?: RawTrackProperties) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const middlewareProperties = Array.from(middlewares).reduce(
|
||||
(acc, middleware) => {
|
||||
return middleware(eventName, acc);
|
||||
},
|
||||
normalizeProperties(properties)
|
||||
);
|
||||
logger.debug('track', eventName, middlewareProperties);
|
||||
const event = buildEvent(eventName, middlewareProperties);
|
||||
void sendTelemetryEvent(event).catch(error => {
|
||||
logger.error('failed to send telemetry event', error);
|
||||
});
|
||||
},
|
||||
|
||||
track_pageview(properties?: { location?: string; [key: string]: unknown }) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const middlewareProperties = Array.from(middlewares).reduce(
|
||||
(acc, middleware) => {
|
||||
return middleware('track_pageview', acc);
|
||||
},
|
||||
normalizeProperties(properties)
|
||||
);
|
||||
const pageLocation =
|
||||
typeof middlewareProperties?.location === 'string'
|
||||
? middlewareProperties.location
|
||||
: getLocationHref();
|
||||
const pageTitle = getDocumentTitle();
|
||||
const params = {
|
||||
...middlewareProperties,
|
||||
location: pageLocation,
|
||||
pageTitle: pageTitle ?? middlewareProperties?.pageTitle,
|
||||
};
|
||||
logger.debug('track_pageview', params);
|
||||
const event = buildEvent('track_pageview', params);
|
||||
void sendTelemetryEvent(event).catch(error => {
|
||||
logger.error('failed to send telemetry pageview', error);
|
||||
});
|
||||
},
|
||||
|
||||
middleware(cb: Middleware): () => void {
|
||||
middlewares.add(cb);
|
||||
return () => {
|
||||
middlewares.delete(cb);
|
||||
};
|
||||
},
|
||||
|
||||
opt_out_tracking() {
|
||||
enabled = false;
|
||||
},
|
||||
|
||||
opt_in_tracking() {
|
||||
enabled = true;
|
||||
},
|
||||
|
||||
has_opted_in_tracking() {
|
||||
return enabled;
|
||||
},
|
||||
|
||||
has_opted_out_tracking() {
|
||||
return !enabled;
|
||||
},
|
||||
|
||||
identify(nextUserId?: string) {
|
||||
userId = nextUserId ? String(nextUserId) : undefined;
|
||||
setTelemetryContext({ userId });
|
||||
},
|
||||
|
||||
get people() {
|
||||
return {
|
||||
set: (props: Record<string, unknown>) => {
|
||||
userProperties = {
|
||||
...userProperties,
|
||||
...props,
|
||||
};
|
||||
setTelemetryContext({ userProperties });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function buildEvent(
|
||||
eventName: string,
|
||||
params?: Record<string, unknown>
|
||||
): TelemetryEvent {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
eventName,
|
||||
params,
|
||||
userId,
|
||||
userProperties,
|
||||
clientId,
|
||||
sessionId,
|
||||
eventId: nanoid(),
|
||||
timestampMicros: Date.now() * 1000,
|
||||
context: buildContext(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildContext(): TelemetryEvent['context'] {
|
||||
return {
|
||||
appVersion: BUILD_CONFIG.appVersion,
|
||||
editorVersion: BUILD_CONFIG.editorVersion,
|
||||
environment: BUILD_CONFIG.appBuildType,
|
||||
distribution: BUILD_CONFIG.distribution,
|
||||
channel: BUILD_CONFIG.appBuildType as NonNullable<
|
||||
TelemetryEvent['context']
|
||||
>['channel'],
|
||||
isDesktop: BUILD_CONFIG.isElectron,
|
||||
isMobile: BUILD_CONFIG.isMobileEdition,
|
||||
locale: getLocale(),
|
||||
timezone: getTimezone(),
|
||||
url: getLocationHref(),
|
||||
referrer: getReferrer(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProperties(properties?: RawTrackProperties): TrackProperties {
|
||||
if (!properties) {
|
||||
return undefined;
|
||||
}
|
||||
return properties as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readPersistentId(key: string, storage: Storage | null, renew = false) {
|
||||
if (!storage) {
|
||||
return nanoid();
|
||||
}
|
||||
if (!renew) {
|
||||
const existing = storage.getItem(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
const id = nanoid();
|
||||
try {
|
||||
storage.setItem(key, id);
|
||||
} catch {
|
||||
return id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function localStorageSafe(): Storage | null {
|
||||
try {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionStorageSafe(): Storage | null {
|
||||
try {
|
||||
return typeof sessionStorage === 'undefined' ? null : sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocale() {
|
||||
try {
|
||||
return typeof navigator === 'undefined' ? undefined : navigator.language;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getTimezone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocationHref() {
|
||||
try {
|
||||
return typeof location === 'undefined' ? undefined : location.href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getReferrer() {
|
||||
try {
|
||||
return typeof document === 'undefined' ? undefined : document.referrer;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getDocumentTitle() {
|
||||
try {
|
||||
return typeof document === 'undefined' ? undefined : document.title;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user