snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "@affine/track",
"type": "module",
"private": true,
"version": "0.27.5",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@affine/debug": "workspace:*",
"@sentry/react": "^10.53.1",
"nanoid": "^5.1.6",
"react": "^19.2.1",
"react-router-dom": "^6.30.4"
},
"devDependencies": {
"@types/react": "^19.0.1",
"vitest": "^4.1.8"
}
}

View File

@@ -0,0 +1,148 @@
/**
* @vitest-environment happy-dom
*/
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { enableAutoTrack, makeTracker } from '../auto';
describe('callable events chain', () => {
const call = vi.fn();
const track = makeTracker(call);
beforeEach(() => {
call.mockClear();
});
test('should call track with event and props', () => {
// @ts-expect-error fake chain
track.pageA.segmentA.moduleA.eventA();
expect(call).toBeCalledWith('eventA', {
page: 'pageA',
segment: 'segmentA',
module: 'moduleA',
});
});
test('should be able to override props', () => {
// @ts-expect-error fake chain
track.pageA.segmentA.moduleA.eventA({ page: 'pageB', control: 'controlA' });
expect(call).toBeCalledWith('eventA', {
page: 'pageB',
segment: 'segmentA',
module: 'moduleA',
control: 'controlA',
});
});
test('should be able to append custom props', () => {
// @ts-expect-error fake chain
track.pageA.segmentA.moduleA.eventA({ custom: 'prop' });
expect(call).toBeCalledWith('eventA', {
page: 'pageA',
segment: 'segmentA',
module: 'moduleA',
custom: 'prop',
});
});
test('should be able to ignore matrix named with placeholder `$`', () => {
// @ts-expect-error fake chain
track.$.segmentA.moduleA.eventA();
// @ts-expect-error fake chain
track.pageA.$.moduleA.eventA();
// @ts-expect-error fake chain
track.pageA.segmentA.$.eventA();
// @ts-expect-error fake chain
track.$.$.$.eventA();
const args = [
{
segment: 'segmentA',
module: 'moduleA',
},
{
page: 'pageA',
module: 'moduleA',
},
{
page: 'pageA',
segment: 'segmentA',
},
{},
];
args.forEach((arg, i) => {
expect(call).toHaveBeenNthCalledWith(i + 1, 'eventA', arg);
});
});
});
describe('auto track with dom dataset', () => {
const root = document.createElement('div');
const call = vi.fn();
beforeAll(() => {
call.mockReset();
root.innerHTML = '';
return enableAutoTrack(root, call);
});
test('should ignore if data-event-props not set', () => {
const nonTrackBtn = document.createElement('button');
root.append(nonTrackBtn);
nonTrackBtn.click();
expect(call).not.toBeCalled();
});
test('should track event with props', () => {
const btn = document.createElement('button');
btn.dataset.eventProps = 'allDocs.header.actions.createDoc';
root.append(btn);
btn.click();
expect(call).toBeCalledWith('createDoc', {
page: 'allDocs',
segment: 'header',
module: 'actions',
});
});
test('should track event with single', () => {
const btn = document.createElement('button');
btn.dataset.eventProps = 'allDocs.header.actions.createDoc';
btn.dataset.eventArg = 'test';
root.append(btn);
btn.click();
expect(call).toBeCalledWith('createDoc', {
page: 'allDocs',
segment: 'header',
module: 'actions',
arg: 'test',
});
});
test('should track event with multiple args', () => {
const btn = document.createElement('button');
btn.dataset.eventProps = 'allDocs.header.actions.createDoc';
btn.dataset.eventArgsFoo = 'bar';
btn.dataset.eventArgsBaz = 'qux';
root.append(btn);
btn.click();
expect(call).toBeCalledWith('createDoc', {
page: 'allDocs',
segment: 'header',
module: 'actions',
foo: 'bar',
baz: 'qux',
});
});
});

View File

@@ -0,0 +1,80 @@
/**
* @vitest-environment happy-dom
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { resetTrackerState } from '../state';
import { tracker } from '../tracker';
const { sendTelemetryEvent, setTelemetryContext } = vi.hoisted(() => ({
sendTelemetryEvent: vi.fn().mockResolvedValue({ queued: true }),
setTelemetryContext: vi.fn(),
}));
vi.mock('../telemetry', () => ({
sendTelemetryEvent,
setTelemetryContext,
}));
const buildConfig = {
appVersion: '0.0.0',
appBuildType: 'stable',
editorVersion: '0.0.0',
isElectron: false,
isMobileEdition: false,
distribution: 'test',
};
beforeEach(() => {
(globalThis as any).BUILD_CONFIG = buildConfig;
localStorage.clear();
sessionStorage.clear();
sendTelemetryEvent.mockClear();
setTelemetryContext.mockClear();
vi.useRealTimers();
resetTrackerState();
});
describe('tracker session signals', () => {
test('sends first_visit and session_start on first event', () => {
tracker.track('test_event');
const events = sendTelemetryEvent.mock.calls.map(call => call[0]);
expect(events.map(event => event.eventName)).toEqual([
'first_visit',
'session_start',
'test_event',
]);
const firstVisit = events[0];
expect(typeof firstVisit.params?.session_id).toBe('number');
expect(firstVisit.params?.session_number).toBe(1);
expect(firstVisit.params?.engagement_time_msec).toBe(1);
});
test('does not repeat first_visit for later events', () => {
tracker.track('event_a');
tracker.track('event_b');
const names = sendTelemetryEvent.mock.calls.map(call => call[0].eventName);
expect(names.filter(name => name === 'first_visit')).toHaveLength(1);
expect(names.filter(name => name === 'session_start')).toHaveLength(1);
});
test('increments session_number after idle timeout', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
tracker.track('event_a');
sendTelemetryEvent.mockClear();
vi.setSystemTime(new Date('2024-01-01T01:00:00Z'));
tracker.track('event_b');
const events = sendTelemetryEvent.mock.calls.map(call => call[0]);
const sessionStart = events.find(
event => event.eventName === 'session_start'
);
expect(sessionStart?.params?.session_number).toBe(2);
});
});

View File

@@ -0,0 +1,125 @@
import { DebugLogger } from '@affine/debug';
import type { CallableEventsChain, EventsUnion } from './types';
const logger = new DebugLogger('mixpanel');
interface TrackFn {
(event: string, props: Record<string, any>): void;
}
const levels = ['page', 'segment', 'module', 'event'] as const;
export function makeTracker(trackFn: TrackFn): CallableEventsChain {
function makeTrackerInner(level: number, info: Record<string, string>) {
const proxy = new Proxy({} as Record<string, any>, {
get(target, prop) {
if (
typeof prop !== 'string' ||
prop === '$$typeof' /* webpack hot-reload reads this prop */
) {
return undefined;
}
if (levels[level] === 'event') {
return (arg: string | Record<string, any>) => {
trackFn(prop, {
...info,
...(typeof arg === 'string' ? { arg } : arg),
});
};
} else {
let levelProxy = target[prop];
if (levelProxy) {
return levelProxy;
}
levelProxy = makeTrackerInner(
level + 1,
prop === '$' ? { ...info } : { ...info, [levels[level]]: prop }
);
target[prop] = levelProxy;
return levelProxy;
}
},
});
return proxy;
}
return makeTrackerInner(0, {}) as CallableEventsChain;
}
/**
* listen on clicking on all subtree elements and auto track events if defined
*
* @example
*
* ```html
* <button
* data-event-chain='$.cmdk.settings.changeLanguage'
* data-event-arg='cn'
* <!-- or -->
* data-event-args-foo='bar'
* />
* ```
*/
export function enableAutoTrack(root: HTMLElement, trackFn: TrackFn) {
const listener = (e: Event) => {
const el = e.target as HTMLElement | null;
if (!el) {
return;
}
const dataset = el.dataset;
if (dataset['eventProps']) {
const args: Record<string, any> = {};
if (dataset['eventArg'] !== undefined) {
args['arg'] = dataset['eventArg'];
} else {
for (const argName of Object.keys(dataset)) {
if (argName.startsWith('eventArgs')) {
args[argName.slice(9).toLowerCase()] = dataset[argName];
}
}
}
const props = dataset['eventProps']
.split('.')
.map(name => (name === '$' ? undefined : name));
if (props.length !== levels.length) {
logger.error('Invalid event props on element', el);
return;
}
const event = props[3];
if (!event) {
logger.error('Invalid event props on element', el);
return;
}
trackFn(event, {
page: props[0] as any,
segment: props[1],
module: props[2],
...args,
});
}
};
root.addEventListener('click', listener, {});
return () => {
root.removeEventListener('click', listener);
};
}
declare module 'react' {
// we have to declare `T` but it's actually not used
// oxlint-disable-next-line no-unused-vars
interface HTMLAttributes<T> {
'data-event-props'?: EventsUnion;
'data-event-arg'?: string;
'data-event-args-control'?: string;
}
}

View File

@@ -0,0 +1,872 @@
// let '$' stands for unspecific matrix
// SECTION: app events
type GeneralEvents = 'openMigrationDataHelp';
type CmdkEvents = 'quickSearch' | 'recentDocs' | 'searchResultsDocs';
type AppEvents =
| 'checkUpdates'
| 'downloadUpdate'
| 'downloadApp'
| 'quitAndInstall'
| 'openChangelog'
| 'dismissChangelog'
| 'contactUs'
| 'findInPage';
type NavigationEvents =
| 'openInNewTab'
| 'openInSplitView'
| 'openInPeekView'
| 'switchTab'
| 'switchSplitView'
| 'tabAction'
| 'splitViewAction'
| 'navigate'
| 'goBack'
| 'goForward'
| 'toggle' // toggle navigation panel
| 'open'
| 'close'; // openclose modal/diaglog
// END SECTIONalias
// SECTION: doc events
type WorkspaceEvents =
| 'createWorkspace'
| 'upgradeWorkspace'
| 'enableCloudWorkspace'
| 'import'
| 'export'
| 'openWorkspaceList';
type DocEvents =
| 'openDoc'
| 'createDoc'
| 'quickStart'
| 'renameDoc'
| 'linkDoc'
| 'deleteDoc'
| 'restoreDoc'
| 'switchPageMode'
| 'openDocOptionsMenu'
| 'openDocInfo'
| 'copyBlockToLink'
| 'loadDoc'
| 'bookmark'
| 'editProperty'
| 'editPropertyMeta'
| 'addProperty'
| 'editDisplayMenu'
| 'navigateAllDocsRouter'
| 'navigatePinedCollectionRouter'
| 'htmlBlockPreviewFailed';
type EditorEvents =
| 'bold'
| 'italic'
| 'underline'
| 'strikeThrough'
| 'foldEdgelessNote';
// END SECTION
// SECTION: setting events
type SettingEvents =
| 'openSettings'
| 'changeAppSetting'
| 'changeEditorSetting'
| 'recoverArchivedWorkspace'
| 'deleteArchivedWorkspace'
| 'deleteUnusedBlob';
// END SECTION
// SECTION: organize events
type CollectionEvents =
| 'createCollection'
| 'deleteCollection'
| 'renameCollection'
| 'addDocToCollection'
| 'editCollection'
| 'addPinnedCollection';
type FolderEvents =
| 'createFolder'
| 'renameFolder'
| 'moveFolder'
| 'deleteFolder';
type TagEvents = 'createTag' | 'deleteTag' | 'renameTag' | 'tagDoc';
type FavoriteEvents = 'toggleFavorite';
type OrganizeItemEvents =
// doc, link, folder, collection, tag
| 'createOrganizeItem'
| 'renameOrganizeItem'
| 'moveOrganizeItem'
| 'deleteOrganizeItem'
| 'orderOrganizeItem'
| 'removeOrganizeItem';
type OrganizeEvents =
| OrganizeItemEvents
| CollectionEvents
| FolderEvents
| TagEvents
| FavoriteEvents;
type DNDEvents = 'dragStart' | 'drag' | 'drop';
// END SECTION
// SECTION: cloud events
type ShareEvents =
| 'createShareLink'
| 'copyShareLink'
| 'openShareMenu'
| 'share';
type DocRoleEvents =
| 'modifyDocDefaultRole'
| 'modifyUserDocRole'
| 'inviteUserDocRole';
type AuthEvents =
| 'requestSignIn'
| 'signIn'
| 'signInFail'
| 'signedIn'
| 'signOut'
| 'deleteAccount';
type AccountEvents = 'uploadAvatar' | 'removeAvatar' | 'updateUserName';
type PaymentEvents =
| 'viewPlans'
| 'bookDemo'
| 'checkout'
| 'subscribe'
| 'changeSubscriptionRecurring'
| 'confirmChangingSubscriptionRecurring'
| 'cancelSubscription'
| 'confirmCancelingSubscription'
| 'resumeSubscription'
| 'confirmResumingSubscription';
// END SECTION
// SECTION: ai
type AIEvents = 'addEmbeddingDoc';
// END SECTION
// SECTION: attachment
type AttachmentEvents =
| 'openAttachmentInFullscreen'
| 'openAttachmentInNewTab'
| 'openAttachmentInPeekView'
| 'openAttachmentInSplitView'
| 'openPDFRendererFail';
// END SECTION
// SECTION: template
type TemplateEvents = 'openTemplateListMenu';
// END SECTION
// SECTION: notification
type NotificationEvents = 'openInbox' | 'clickNotification';
// END SECTION
// SECTION: Integration
type IntegrationEvents =
| 'connectIntegration'
| 'disconnectIntegration'
| 'modifyIntegrationSettings'
| 'startIntegrationImport'
| 'selectIntegrationImport'
| 'confirmIntegrationImport'
| 'abortIntegrationImport'
| 'completeIntegrationImport'
| 'createCalendarDocEvent';
// END SECTION
// SECTION: journal
type MeetingEvents =
| 'toggleRecordingBar'
| 'startRecording'
| 'dismissRecording'
| 'finishRecording'
| 'transcribeRecording'
| 'openTranscribeNotes'
| 'toggleMeetingFeatureFlag'
| 'activeMenubarAppItem';
// END SECTION
// SECTION: mention
type MentionEvents = 'mentionMember' | 'noAccessPrompted';
// END SECTION
// SECTION: workspace embedding
type WorkspaceEmbeddingEvents =
| 'toggleWorkspaceEmbedding'
| 'addAdditionalDocs'
| 'addIgnoredDocs';
// END SECTION
// SECTION: comment events
// Add events for comment actions
type CommentEvents =
| 'createComment'
| 'editComment'
| 'deleteComment'
| 'resolveComment';
// END SECTION
// SECTION: apply model
type ApplyModelEvents =
| 'acceptAll'
| 'rejectAll'
| 'accept'
| 'reject'
| 'apply'
| 'copy';
// END SECTION
type UserEvents =
| GeneralEvents
| AppEvents
| NavigationEvents
| WorkspaceEvents
| DocEvents
| EditorEvents
| SettingEvents
| CmdkEvents
| OrganizeEvents
| ShareEvents
| DocRoleEvents
| AuthEvents
| AccountEvents
| PaymentEvents
| DNDEvents
| AIEvents
| CommentEvents
| AttachmentEvents
| TemplateEvents
| NotificationEvents
| IntegrationEvents
| MeetingEvents
| MentionEvents
| WorkspaceEmbeddingEvents
| ApplyModelEvents;
interface PageDivision {
[page: string]: {
[segment: string]: {
[module: string]: UserEvents[];
};
};
}
interface PageEvents extends PageDivision {
// page: {
// $: {}
// ^ if empty
// segment: {
// module: ['event1', 'event2']
// },
// },
// to: page.$.segment.module.event1()
$: {
$: {
$: ['createWorkspace', 'checkout'];
auth: [
'requestSignIn',
'signIn',
'signedIn',
'signInFail',
'signOut',
'deleteAccount',
];
};
sharePanel: {
$: [
'createShareLink',
'copyShareLink',
'export',
'open',
'modifyDocDefaultRole',
'modifyUserDocRole',
'inviteUserDocRole',
];
};
docInfoPanel: {
$: ['open'];
property: ['editProperty', 'addProperty', 'editPropertyMeta'];
databaseProperty: ['editProperty'];
};
settingsPanel: {
menu: ['openSettings'];
workspace: [
'viewPlans',
'export',
'addProperty',
'editPropertyMeta',
'deleteUnusedBlob',
];
archivedWorkspaces: [
'recoverArchivedWorkspace',
'deleteArchivedWorkspace',
];
profileAndBadge: ['viewPlans'];
accountUsage: ['viewPlans'];
accountSettings: ['uploadAvatar', 'removeAvatar', 'updateUserName'];
plans: [
'checkout',
'subscribe',
'changeSubscriptionRecurring',
'confirmChangingSubscriptionRecurring',
'cancelSubscription',
'confirmCancelingSubscription',
'resumeSubscription',
'confirmResumingSubscription',
];
billing: ['viewPlans', 'bookDemo'];
about: ['checkUpdates', 'downloadUpdate', 'changeAppSetting'];
integrationList: [
'connectIntegration',
'disconnectIntegration',
'modifyIntegrationSettings',
'startIntegrationImport',
'selectIntegrationImport',
'confirmIntegrationImport',
'abortIntegrationImport',
'completeIntegrationImport',
];
meetings: ['toggleMeetingFeatureFlag'];
indexerEmbedding: [
'toggleWorkspaceEmbedding',
'addAdditionalDocs',
'addIgnoredDocs',
];
};
cmdk: {
recent: ['recentDocs'];
results: ['searchResultsDocs'];
general: ['copyShareLink', 'goBack', 'goForward', 'findInPage'];
creation: ['createDoc'];
workspace: ['createWorkspace'];
settings: ['openSettings', 'changeAppSetting'];
navigation: ['navigate'];
editor: [
'toggleFavorite',
'switchPageMode',
'createDoc',
'export',
'deleteDoc',
'restoreDoc',
];
docInfo: ['open'];
docHistory: ['open'];
updates: ['quitAndInstall'];
help: ['contactUs', 'openChangelog'];
};
navigationPanel: {
$: ['quickSearch', 'createDoc', 'navigate', 'openSettings', 'toggle'];
organize: [
'createOrganizeItem',
'renameOrganizeItem',
'moveOrganizeItem',
'deleteOrganizeItem',
'orderOrganizeItem',
'openInNewTab',
'openInSplitView',
'toggleFavorite',
'drop',
];
docs: ['createDoc', 'deleteDoc', 'linkDoc', 'drop', 'openDoc'];
collections: [
'createDoc',
'addDocToCollection',
'removeOrganizeItem',
'drop',
'editCollection',
];
folders: ['createDoc', 'drop'];
tags: ['createDoc', 'tagDoc', 'drop'];
favorites: ['createDoc', 'drop'];
migrationData: ['openMigrationDataHelp'];
bottomButtons: [
'downloadApp',
'quitAndInstall',
'openChangelog',
'dismissChangelog',
];
others: ['navigate'];
importModal: ['open'];
workspaceList: [
'requestSignIn',
'open',
'createWorkspace',
'createDoc',
'openSettings',
];
profileAndBadge: ['openSettings'];
journal: ['navigate'];
};
aiOnboarding: {
dialog: ['viewPlans'];
};
docHistory: {
$: ['open', 'close', 'switchPageMode', 'viewPlans'];
};
importModal: {
$: ['open', 'import', 'createDoc'];
};
paywall: {
storage: ['viewPlans'];
aiAction: ['viewPlans'];
};
appTabsHeader: {
$: ['tabAction', 'dragStart'];
};
header: {
$: ['dragStart'];
actions: [
'createDoc',
'createWorkspace',
'switchPageMode',
'toggleFavorite',
'openDocInfo',
'renameDoc',
];
docOptions: [
'open',
'deleteDoc',
'renameDoc',
'switchPageMode',
'createDoc',
'import',
'toggleFavorite',
'export',
];
history: ['open'];
pageInfo: ['open'];
importModal: ['open'];
snapshot: ['import', 'export'];
};
chatPanel: {
chatPanelInput: ['addEmbeddingDoc'];
};
intelligence: {
chatPanelInput: ['addEmbeddingDoc'];
};
commentPanel: {
$: ['createComment', 'editComment', 'deleteComment', 'resolveComment'];
};
attachment: {
$: [
'openAttachmentInFullscreen',
'openAttachmentInNewTab',
'openAttachmentInPeekView',
'openAttachmentInSplitView',
'openPDFRendererFail',
];
};
sidebar: {
newDoc: ['quickStart'];
template: ['openTemplateListMenu', 'quickStart'];
notifications: ['openInbox', 'clickNotification'];
};
splitViewIndicator: {
$: ['splitViewAction', 'openInSplitView', 'openInPeekView'];
};
};
doc: {
$: {
$: ['loadDoc'];
};
editor: {
slashMenu: ['linkDoc', 'createDoc', 'bookmark'];
atMenu: [
'linkDoc',
'import',
'createDoc',
'mentionMember',
'noAccessPrompted',
];
quickSearch: ['createDoc'];
formatToolbar: ['bold'];
pageRef: ['navigate'];
toolbar: [
'copyBlockToLink',
'openInSplitView',
'openInNewTab',
'openInPeekView',
];
aiActions: ['requestSignIn'];
starterBar: ['quickStart', 'openTemplateListMenu'];
audioBlock: ['transcribeRecording', 'openTranscribeNotes'];
codeBlock: ['htmlBlockPreviewFailed'];
};
inlineDocInfo: {
$: ['toggle'];
property: ['editProperty', 'editPropertyMeta', 'addProperty'];
databaseProperty: ['editProperty'];
};
sidepanel: {
property: ['addProperty', 'editPropertyMeta'];
journal: ['createCalendarDocEvent'];
};
biDirectionalLinksPanel: {
$: ['toggle'];
backlinkTitle: ['toggle', 'navigate'];
backlinkPreview: ['navigate'];
};
};
edgeless: {
pageBlock: {
headerToolbar: [
'toggle',
'openDocInfo',
'copyBlockToLink',
'switchPageMode',
];
};
};
workspace: {
$: {
$: ['upgradeWorkspace'];
};
};
allDocs: {
header: {
navigation: ['navigateAllDocsRouter', 'navigatePinedCollectionRouter'];
actions: ['createDoc', 'createWorkspace'];
displayMenu: ['editDisplayMenu'];
viewMode: ['editDisplayMenu'];
collection: ['editCollection', 'addPinnedCollection'];
};
list: {
doc: ['openDoc'];
docMenu: [
'createDoc',
'deleteDoc',
'openInSplitView',
'toggleFavorite',
'openInNewTab',
'openDocInfo',
];
};
};
collection: {
docList: {
docMenu: ['removeOrganizeItem'];
};
collection: {
$: ['editCollection'];
};
};
tag: {};
trash: {};
subscriptionLanding: {
$: {
$: ['checkout'];
};
};
menubarApp: {
menubarActionsMenu: {
menubarActionsList: ['activeMenubarAppItem', 'startRecording'];
};
};
popup: {
$: {
recordingBar: [
'toggleRecordingBar',
'startRecording',
'dismissRecording',
'finishRecording',
];
};
};
clipper: {
$: {
$: ['createDoc'];
};
};
applyModel: {
widget: {
page: ['acceptAll', 'rejectAll'];
block: ['accept', 'reject'];
};
chat: {
$: ['apply', 'accept', 'reject', 'copy'];
};
};
}
type OrganizeItemType = 'doc' | 'folder' | 'collection' | 'tag' | 'favorite';
type OrganizeItemArgs =
| {
type: 'link';
target: OrganizeItemType;
}
| {
type: OrganizeItemType;
};
type PaymentEventArgs = {
plan: string;
recurring: string;
};
type AttachmentEventArgs = {
type: string; // file type
};
type TabActionControlType =
| 'click'
| 'dnd'
| 'midClick'
| 'xButton'
| 'contextMenu';
type TabActionType =
| 'pin'
| 'unpin'
| 'close'
| 'refresh'
| 'moveTab'
| 'openInSplitView'
| 'openInNewTab'
| 'switchSplitView'
| 'switchTab'
| 'separateTabs';
type SplitViewActionControlType = 'menu' | 'indicator';
type SplitViewActionType = 'open' | 'close' | 'move' | 'closeOthers';
type AuthArgs = {
method: 'password' | 'magic-link' | 'oauth' | 'otp';
provider?: string;
};
type ImportStatus = 'importing' | 'failed' | 'success';
type ImportArgs = {
type: string;
status?: ImportStatus;
error?: string;
result?: {
docCount: number;
};
};
type IntegrationArgs<T extends Record<string, any>> = {
type: string;
control:
| 'Readwise Card'
| 'Readwise settings'
| 'Readwise import list'
| 'Calendar Setting';
} & T;
type RecordingEventArgs = {
type: 'Meeting record';
method?: string;
option?: 'Auto transcribing' | 'handle transcribing' | 'on' | 'off';
};
type ApplyModelArgs = {
/* ​​User's complete instruction */
instruction?: string;
/* Split individual semantic change requests */
operation?: string;
};
export type EventArgs = {
createWorkspace: { flavour: string };
signIn: AuthArgs;
signedIn: AuthArgs;
signInFail: AuthArgs & { reason: string };
viewPlans: PaymentEventArgs;
checkout: PaymentEventArgs;
subscribe: PaymentEventArgs;
cancelSubscription: PaymentEventArgs;
confirmCancelingSubscription: PaymentEventArgs;
resumeSubscription: PaymentEventArgs;
confirmResumingSubscription: PaymentEventArgs;
changeSubscriptionRecurring: PaymentEventArgs;
confirmChangingSubscriptionRecurring: PaymentEventArgs;
navigate: { to: string };
openSettings: { to: string };
changeAppSetting: { key: string; value: string | boolean | number };
changeEditorSetting: { key: string; value: string | boolean | number };
createOrganizeItem: OrganizeItemArgs;
renameOrganizeItem: OrganizeItemArgs;
moveOrganizeItem: OrganizeItemArgs;
removeOrganizeItem: OrganizeItemArgs;
deleteOrganizeItem: OrganizeItemArgs;
orderOrganizeItem: OrganizeItemArgs;
openInNewTab: { type: OrganizeItemType };
openInSplitView: { type: OrganizeItemType; route?: string };
tabAction: {
type?: OrganizeItemType;
control: TabActionControlType;
action: TabActionType;
};
splitViewAction: {
control: SplitViewActionControlType;
action: SplitViewActionType;
};
toggleFavorite: OrganizeItemArgs & { on: boolean };
toggle: { type: 'collapse' | 'expand' };
createDoc: { mode?: 'edgeless' | 'page' };
quickStart: { with: 'page' | 'edgeless' | 'template' | 'ai' };
switchPageMode: { mode: 'edgeless' | 'page' };
createShareLink: { mode: 'edgeless' | 'page' };
copyShareLink: {
type: 'default' | 'doc' | 'whiteboard' | 'block' | 'element';
};
import: ImportArgs;
export: { type: string };
copyBlockToLink: {
type: string;
};
editProperty: { type: string };
editPropertyMeta: { type: string; field: string };
addProperty: { type: string; control: 'at menu' | 'property list' };
linkDoc: { type: string; journal: boolean };
drop: { type: string };
dragStart: { type: string };
addEmbeddingDoc: {
type?: 'page' | 'edgeless';
control: 'addButton' | 'atMenu' | 'dragDrop';
method: 'doc' | 'cur-doc' | 'file' | 'tags' | 'collections' | 'suggestion';
};
openAttachmentInFullscreen: AttachmentEventArgs;
openAttachmentInNewTab: AttachmentEventArgs;
openAttachmentInPeekView: AttachmentEventArgs;
openAttachmentInSplitView: AttachmentEventArgs;
modifyUserDocRole: { role: string };
modifyDocDefaultRole: { role: string };
inviteUserDocRole: {
control: 'member list';
role: string;
};
openInbox: { unreadCount: number };
clickNotification: {
type: string;
item: 'read' | 'button' | 'dismiss';
button?: string;
};
connectIntegration: IntegrationArgs<{ result: 'success' | 'failed' }>;
disconnectIntegration: IntegrationArgs<{ method: 'keep' | 'delete' }>;
modifyIntegrationSettings: IntegrationArgs<{
item: string;
option: any;
method: any;
}>;
startIntegrationImport: IntegrationArgs<{
method: 'new' | 'withtimestamp' | 'cleartimestamp';
}>;
selectIntegrationImport: IntegrationArgs<{
method: 'single' | 'all';
option: 'on' | 'off';
}>;
confirmIntegrationImport: IntegrationArgs<{
method: 'new' | 'withtimestamp';
}>;
abortIntegrationImport: IntegrationArgs<{
time: number;
done: number;
total: number;
}>;
completeIntegrationImport: IntegrationArgs<{
time: number;
done: number;
total: number;
}>;
toggleRecordingBar: RecordingEventArgs & {
method: string;
appName: string;
};
startRecording: RecordingEventArgs & {
method: string;
appName: string;
};
dismissRecording: RecordingEventArgs & {
method: string;
appName: string;
};
finishRecording: RecordingEventArgs & {
method: 'fail' | 'success';
appName: string;
};
transcribeRecording: RecordingEventArgs & {
method: 'fail' | 'success';
option: 'Auto transcribing' | 'handle transcribing';
};
openTranscribeNotes: RecordingEventArgs & {
method: 'success' | 'reach limit' | 'not signed in' | 'not owner';
option: 'on' | 'off';
};
toggleMeetingFeatureFlag: RecordingEventArgs & {
option: 'on' | 'off';
};
activeMenubarAppItem: RecordingEventArgs & {
control:
| 'Open Journal'
| 'New Page'
| 'New Edgeless'
| 'Start recording meeting'
| 'Stop recording'
| 'Open AFFiNE'
| 'About AFFiNE'
| 'Meeting Settings'
| 'Quit AFFiNE Completely';
};
mentionMember: {
type: 'member' | 'invite' | 'more';
};
htmlBlockPreviewFailed: {
type: string;
};
noAccessPrompted: {};
loadDoc: {
workspaceId: string;
docId: string;
time: number;
success: boolean;
};
toggleWorkspaceEmbedding: {
type: 'Embedding';
control: 'Workspace embedding';
option: 'on' | 'off';
};
addAdditionalDocs: {
type: 'Embedding';
control: 'Select doc';
docType: string;
};
addIgnoredDocs: {
type: 'Embedding';
control: 'Additional docs';
result: 'success' | 'failure';
};
editDisplayMenu: {
control:
| 'groupBy'
| 'orderBy'
| 'displayProperties'
| 'listViewOptions'
| 'quickActions';
type: string;
};
navigateAllDocsRouter: {
control: string;
};
navigatePinedCollectionRouter: {
control: 'all' | 'user-custom-collection';
};
resolveComment: { type: 'on' | 'off' };
createComment: {
type: 'root' | 'node';
withAttachment: boolean;
withMention: boolean;
category: string;
};
editComment: { type: 'root' | 'node' };
deleteComment: { type: 'root' | 'node' };
accept: ApplyModelArgs;
reject: ApplyModelArgs;
apply: ApplyModelArgs;
};
// for type checking
// if it complains, check the definition of [EventArgs] to make sure it's key is a subset of [UserEvents]
export const YOU_MUST_DEFINE_ARGS_WITH_WRONG_EVENT_NAME: keyof EventArgs extends UserEvents
? true
: false = true;
export type Events = PageEvents;

View File

@@ -0,0 +1,24 @@
import { enableAutoTrack, makeTracker } from './auto';
import { type EventArgs, type Events } from './events';
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,
flushTelemetry,
sentry,
setTelemetryContext,
setTelemetryTransport,
tracker,
};
export default track;

View File

@@ -0,0 +1,53 @@
import * as Sentry from '@sentry/react';
import { useEffect } from 'react';
import {
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} from 'react-router-dom';
function createSentry() {
let client: Sentry.BrowserClient | undefined;
const wrapped = {
init() {
if (!globalThis.SENTRY_RELEASE) {
// https://docs.sentry.io/platforms/javascript/guides/react/#configure
client = Sentry.init({
dsn: BUILD_CONFIG.SENTRY_DSN,
debug: BUILD_CONFIG.debug ?? false,
environment: BUILD_CONFIG.appBuildType,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
}) as Sentry.BrowserClient;
Sentry.setTags({
distribution: BUILD_CONFIG.distribution,
appVersion: BUILD_CONFIG.appVersion,
editorVersion: BUILD_CONFIG.editorVersion,
});
}
},
enable() {
if (client) {
client.getOptions().enabled = true;
}
},
disable() {
if (client) {
client.getOptions().enabled = false;
}
},
};
return wrapped;
}
export const sentry = createSentry();

View File

@@ -0,0 +1,99 @@
import { nanoid } from 'nanoid';
export type TrackProperties = Record<string, unknown> | undefined;
export type Middleware = (
name: string,
properties?: TrackProperties
) => Record<string, unknown>;
type TrackerState = {
enabled: boolean;
clientStorage: Storage | null;
clientId: string;
pendingFirstVisit: boolean;
sessionId: number;
sessionNumber: number;
lastActivityMs: number;
sessionStartSent: boolean;
engagementTrackingEnabled: boolean;
visibleSinceMs: number | null;
pendingEngagementMs: number;
visibilityChangeHandler: (() => void) | null;
pageHideHandler: (() => void) | null;
userId: string | undefined;
userProperties: Record<string, unknown>;
middlewares: Set<Middleware>;
};
const CLIENT_ID_KEY = 'affine_telemetry_client_id';
export let trackerState = createTrackerState();
export function resetTrackerState() {
cleanupTrackerState(trackerState);
trackerState = createTrackerState();
}
function createTrackerState(): TrackerState {
const clientStorage = localStorageSafe();
const hasClientId = !!clientStorage?.getItem(CLIENT_ID_KEY);
return {
enabled: true,
clientStorage,
clientId: readPersistentId(CLIENT_ID_KEY, clientStorage),
pendingFirstVisit: !hasClientId,
sessionId: 0,
sessionNumber: 0,
lastActivityMs: 0,
sessionStartSent: false,
engagementTrackingEnabled: false,
visibleSinceMs: null,
pendingEngagementMs: 0,
visibilityChangeHandler: null,
pageHideHandler: null,
userId: undefined,
userProperties: {},
middlewares: new Set<Middleware>(),
};
}
function cleanupTrackerState(state: TrackerState) {
if (state.visibilityChangeHandler && typeof document !== 'undefined') {
document.removeEventListener(
'visibilitychange',
state.visibilityChangeHandler
);
}
if (state.pageHideHandler && typeof window !== 'undefined') {
window.removeEventListener('pagehide', state.pageHideHandler);
}
}
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;
}
}

View 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 | number;
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);
});
}

View File

@@ -0,0 +1,505 @@
import { DebugLogger } from '@affine/debug';
import { nanoid } from 'nanoid';
import { type Middleware, trackerState, type TrackProperties } from './state';
import type { TelemetryEvent } from './telemetry';
import { sendTelemetryEvent, setTelemetryContext } from './telemetry';
const logger = new DebugLogger('telemetry');
type RawTrackProperties = Record<string, unknown> | object | undefined;
const SESSION_ID_KEY = 'affine_telemetry_session_id';
const SESSION_NUMBER_KEY = 'affine_telemetry_session_number';
const SESSION_NUMBER_CURRENT_KEY = 'affine_telemetry_session_number_current';
const LAST_ACTIVITY_KEY = 'affine_telemetry_last_activity_ms';
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
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>) {
trackerState.userProperties = {
...trackerState.userProperties,
...props,
};
setTelemetryContext({ userProperties: trackerState.userProperties });
},
reset() {
trackerState.userId = undefined;
trackerState.userProperties = {};
startNewSession(Date.now(), sessionStorageSafe());
setTelemetryContext(
{
userId: trackerState.userId,
userProperties: trackerState.userProperties,
},
{ replaceUserProperties: true }
);
this.init();
},
track(eventName: string, properties?: RawTrackProperties) {
if (!trackerState.enabled) {
return;
}
const middlewareProperties = Array.from(trackerState.middlewares).reduce(
(acc, middleware) => {
return middleware(eventName, acc);
},
normalizeProperties(properties)
);
logger.debug('track', eventName, middlewareProperties);
dispatchEvents(buildQueuedEvents(eventName, middlewareProperties));
},
track_pageview(properties?: { location?: string; [key: string]: unknown }) {
if (!trackerState.enabled) {
return;
}
const middlewareProperties = Array.from(trackerState.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);
dispatchEvents(buildQueuedEvents('track_pageview', params));
},
middleware(cb: Middleware): () => void {
trackerState.middlewares.add(cb);
return () => {
trackerState.middlewares.delete(cb);
};
},
opt_out_tracking() {
trackerState.enabled = false;
},
opt_in_tracking() {
trackerState.enabled = true;
},
has_opted_in_tracking() {
return trackerState.enabled;
},
has_opted_out_tracking() {
return !trackerState.enabled;
},
identify(nextUserId?: string) {
trackerState.userId = nextUserId ? String(nextUserId) : undefined;
setTelemetryContext({ userId: trackerState.userId });
},
get people() {
return {
set: (props: Record<string, unknown>) => {
trackerState.userProperties = {
...trackerState.userProperties,
...props,
};
setTelemetryContext({ userProperties: trackerState.userProperties });
},
};
},
};
function dispatchEvents(events: TelemetryEvent[]) {
for (const event of events) {
void sendTelemetryEvent(event).catch(error => {
logger.error(`failed to send telemetry event ${event.eventName}`, error);
});
}
}
function buildQueuedEvents(
eventName: string,
params?: Record<string, unknown>,
options: { now?: number; engagementMs?: number } = {}
) {
const now = options.now ?? Date.now();
const {
sessionId: nextSessionId,
sessionNumber: nextSessionNumber,
preEvents,
} = prepareSession(now);
const engagementMs = options.engagementMs ?? consumeEngagementTime(now);
const eventParams = mergeSessionParams(
params,
nextSessionId,
nextSessionNumber,
engagementMs
);
return [...preEvents, buildEvent(eventName, eventParams)];
}
function prepareSession(now: number) {
const sessionStorage = sessionStorageSafe();
if (sessionStorage) {
const storedSessionId = readPositiveNumber(sessionStorage, SESSION_ID_KEY);
const storedLastActivity = readPositiveNumber(
sessionStorage,
LAST_ACTIVITY_KEY
);
const expired =
!storedSessionId ||
!storedLastActivity ||
now - storedLastActivity > SESSION_TIMEOUT_MS;
if (expired) {
startNewSession(now, sessionStorage);
} else {
trackerState.sessionId = storedSessionId;
trackerState.sessionNumber = readCurrentSessionNumber(
sessionStorage,
trackerState.clientStorage
);
updateLastActivity(now, sessionStorage);
}
} else {
const expired =
!trackerState.sessionId ||
!trackerState.lastActivityMs ||
now - trackerState.lastActivityMs > SESSION_TIMEOUT_MS;
if (expired) {
startNewSession(now, null);
} else {
trackerState.lastActivityMs = now;
if (!trackerState.sessionNumber) {
trackerState.sessionNumber = 1;
}
}
}
const preEvents: TelemetryEvent[] = [];
if (trackerState.pendingFirstVisit) {
trackerState.pendingFirstVisit = false;
preEvents.push(
buildEvent(
'first_visit',
mergeSessionParams(
{},
trackerState.sessionId,
trackerState.sessionNumber,
1
)
)
);
}
if (!trackerState.sessionStartSent) {
trackerState.sessionStartSent = true;
preEvents.push(
buildEvent(
'session_start',
mergeSessionParams(
{},
trackerState.sessionId,
trackerState.sessionNumber,
1
)
)
);
}
return {
sessionId: trackerState.sessionId,
sessionNumber: trackerState.sessionNumber,
preEvents,
};
}
function mergeSessionParams(
params: Record<string, unknown> | undefined,
nextSessionId: number,
nextSessionNumber: number,
engagementMs: number
) {
const merged: Record<string, unknown> = {
...params,
};
if (Number.isFinite(nextSessionId) && nextSessionId > 0) {
merged.session_id = nextSessionId;
}
if (Number.isFinite(nextSessionNumber) && nextSessionNumber > 0) {
merged.session_number = nextSessionNumber;
}
if (Number.isFinite(engagementMs)) {
merged.engagement_time_msec = engagementMs;
}
return merged;
}
function startNewSession(now: number, sessionStorage: Storage | null) {
trackerState.sessionId = Math.floor(now / 1000);
trackerState.sessionNumber = incrementSessionNumber(
trackerState.clientStorage,
sessionStorage
);
updateLastActivity(now, sessionStorage);
writeNumber(sessionStorage, SESSION_ID_KEY, trackerState.sessionId);
trackerState.sessionStartSent = false;
resetEngagementState(now);
}
function updateLastActivity(now: number, sessionStorage: Storage | null) {
trackerState.lastActivityMs = now;
writeNumber(sessionStorage, LAST_ACTIVITY_KEY, now);
}
function consumeEngagementTime(now: number) {
initEngagementTracking(now);
if (trackerState.visibleSinceMs !== null) {
trackerState.pendingEngagementMs += now - trackerState.visibleSinceMs;
trackerState.visibleSinceMs = now;
}
const engagementMs = Math.max(
0,
Math.round(trackerState.pendingEngagementMs)
);
trackerState.pendingEngagementMs = 0;
return engagementMs;
}
function resetEngagementState(now: number) {
trackerState.pendingEngagementMs = 0;
trackerState.visibleSinceMs = isDocumentVisible() ? now : null;
}
function initEngagementTracking(now: number) {
if (
trackerState.engagementTrackingEnabled ||
typeof document === 'undefined'
) {
return;
}
trackerState.engagementTrackingEnabled = true;
resetEngagementState(now);
trackerState.visibilityChangeHandler = () => {
const now = Date.now();
if (trackerState.visibleSinceMs !== null) {
trackerState.pendingEngagementMs += now - trackerState.visibleSinceMs;
}
trackerState.visibleSinceMs = isDocumentVisible() ? now : null;
if (!isDocumentVisible()) {
dispatchUserEngagement(now);
}
};
document.addEventListener(
'visibilitychange',
trackerState.visibilityChangeHandler
);
if (typeof window !== 'undefined') {
trackerState.pageHideHandler = () => {
dispatchUserEngagement(Date.now());
};
window.addEventListener('pagehide', trackerState.pageHideHandler);
}
}
function dispatchUserEngagement(now: number) {
if (!trackerState.enabled) {
return;
}
const engagementMs = consumeEngagementTime(now);
if (engagementMs <= 0) {
return;
}
dispatchEvents(
buildQueuedEvents(
'user_engagement',
{ engagement_time_msec: engagementMs },
{ now, engagementMs }
)
);
}
function isDocumentVisible() {
try {
return (
typeof document !== 'undefined' && document.visibilityState !== 'hidden'
);
} catch {
return true;
}
}
function readPositiveNumber(storage: Storage | null, key: string) {
if (!storage) {
return undefined;
}
const raw = storage.getItem(key);
if (!raw) {
return undefined;
}
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
return undefined;
}
return value;
}
function writeNumber(storage: Storage | null, key: string, value: number) {
if (!storage) {
return;
}
try {
storage.setItem(key, String(value));
} catch {
return;
}
}
function readCurrentSessionNumber(
sessionStorage: Storage,
localStorage: Storage | null
) {
const current = readPositiveNumber(
sessionStorage,
SESSION_NUMBER_CURRENT_KEY
);
if (current) {
return current;
}
const fallback = localStorage
? (readPositiveNumber(localStorage, SESSION_NUMBER_KEY) ?? 1)
: trackerState.sessionNumber || 1;
writeNumber(sessionStorage, SESSION_NUMBER_CURRENT_KEY, fallback);
if (localStorage && !readPositiveNumber(localStorage, SESSION_NUMBER_KEY)) {
writeNumber(localStorage, SESSION_NUMBER_KEY, fallback);
}
return fallback;
}
function incrementSessionNumber(
localStorage: Storage | null,
sessionStorage: Storage | null
) {
if (!localStorage) {
const next = (trackerState.sessionNumber || 0) + 1;
writeNumber(sessionStorage, SESSION_NUMBER_CURRENT_KEY, next);
return next;
}
const current = readPositiveNumber(localStorage, SESSION_NUMBER_KEY) ?? 0;
const next = current + 1;
writeNumber(localStorage, SESSION_NUMBER_KEY, next);
writeNumber(sessionStorage, SESSION_NUMBER_CURRENT_KEY, next);
return next;
}
function buildEvent(
eventName: string,
params?: Record<string, unknown>
): TelemetryEvent {
return {
schemaVersion: 1,
eventName,
params,
userId: trackerState.userId,
userProperties: trackerState.userProperties,
clientId: trackerState.clientId,
sessionId: trackerState.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 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;
}
}

View File

@@ -0,0 +1,70 @@
import type { EventArgs, Events } from './events';
type EventPropsOverride = {
page?: keyof Events;
segment?: string;
module?: string;
control?: string;
};
export type CallableEventsChain = {
[Page in keyof Events]: {
[Segment in keyof Events[Page]]: {
[Module in keyof Events[Page][Segment]]: {
[
Event in Events[Page][Segment][Module][number]
]: Event extends keyof EventArgs
? (
// we make all args partial to simply satisfies nullish type checking
args?: Partial<EventArgs[Event]> & EventPropsOverride
) => void
: (args?: EventPropsOverride) => void;
};
};
};
};
export type EventsUnion = {
[Page in keyof Events]: {
[Segment in keyof Events[Page]]: {
[Module in keyof Events[Page][Segment]]: {
[
Event in Events[Page][Segment][Module][number]
]: `${Page & string}.${Segment & string}.${Module & string}.${Event & string}`;
}[Events[Page][Segment][Module][number]];
}[keyof Events[Page][Segment]];
}[keyof Events[Page]];
}[keyof Events];
// page > segment > module > [events]
type IsFourLevelsDeep<
T,
Depth extends number[] = [],
> = Depth['length'] extends 3
? T extends Array<any>
? true
: false
: T extends object
? {
[K in keyof T]: IsFourLevelsDeep<T[K], [...Depth, 0]>;
}[keyof T] extends true
? true
: false
: false;
// for type checking
export const _assertIsAllEventsDefinedInFourLevels: IsFourLevelsDeep<Events> = true;
export interface EventProps {
// location
page?: keyof Events;
segment?: string;
module?: string;
control?: string;
arg?: string;
// entity
type?: string;
category?: string;
id?: string;
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.web.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [{ "path": "../../common/debug" }]
}