feat(core): make event track great again (#7695)
 
This commit is contained in:
@@ -6,7 +6,7 @@ import type { createStore } from 'jotai';
|
|||||||
|
|
||||||
import { openSettingModalAtom, openWorkspaceListModalAtom } from '../atoms';
|
import { openSettingModalAtom, openWorkspaceListModalAtom } from '../atoms';
|
||||||
import type { useNavigateHelper } from '../hooks/use-navigate-helper';
|
import type { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||||
import { mixpanel } from '../mixpanel';
|
import { mixpanel, track } from '../mixpanel';
|
||||||
import { registerAffineCommand } from './registry';
|
import { registerAffineCommand } from './registry';
|
||||||
|
|
||||||
export function registerAffineNavigationCommands({
|
export function registerAffineNavigationCommands({
|
||||||
@@ -77,10 +77,7 @@ export function registerAffineNavigationCommands({
|
|||||||
label: t['com.affine.cmdk.affine.navigation.open-settings'](),
|
label: t['com.affine.cmdk.affine.navigation.open-settings'](),
|
||||||
keyBinding: '$mod+,',
|
keyBinding: '$mod+,',
|
||||||
run() {
|
run() {
|
||||||
mixpanel.track('SettingsViewed', {
|
track.$.cmdk.settings.openSettings();
|
||||||
// page:
|
|
||||||
segment: 'cmdk',
|
|
||||||
});
|
|
||||||
store.set(openSettingModalAtom, s => ({
|
store.set(openSettingModalAtom, s => ({
|
||||||
activeTab: 'appearance',
|
activeTab: 'appearance',
|
||||||
open: !s.open,
|
open: !s.open,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { getDowngradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
|
import { getDowngradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import { mixpanel } from '@affine/core/mixpanel';
|
import { mixpanel } from '@affine/core/mixpanel';
|
||||||
import type { MixpanelEvents } from '@affine/core/mixpanel/events';
|
|
||||||
import { SubscriptionPlan } from '@affine/graphql';
|
import { SubscriptionPlan } from '@affine/graphql';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
@@ -25,7 +24,7 @@ export const CancelAction = ({
|
|||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
module: MixpanelEvents['PlanChangeStarted']['module'];
|
module: string;
|
||||||
} & PropsWithChildren) => {
|
} & PropsWithChildren) => {
|
||||||
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
|
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
|
||||||
const [isMutating, setIsMutating] = useState(false);
|
const [isMutating, setIsMutating] = useState(false);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useDowngradeNotify } from '@affine/core/components/affine/subscription-
|
|||||||
import { getDowngradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
|
import { getDowngradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import { mixpanel } from '@affine/core/mixpanel';
|
import { mixpanel } from '@affine/core/mixpanel';
|
||||||
import type { MixpanelEvents } from '@affine/core/mixpanel/events';
|
|
||||||
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
|
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
|
||||||
import { SubscriptionPlan } from '@affine/graphql';
|
import { SubscriptionPlan } from '@affine/graphql';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -12,7 +11,7 @@ import { nanoid } from 'nanoid';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export interface AICancelProps extends ButtonProps {
|
export interface AICancelProps extends ButtonProps {
|
||||||
module: MixpanelEvents['PlanChangeStarted']['module'];
|
module: string;
|
||||||
}
|
}
|
||||||
export const AICancel = ({ module, ...btnProps }: AICancelProps) => {
|
export const AICancel = ({ module, ...btnProps }: AICancelProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import { mixpanel } from '@affine/core/mixpanel';
|
import { mixpanel } from '@affine/core/mixpanel';
|
||||||
import type { MixpanelEvents } from '@affine/core/mixpanel/events';
|
|
||||||
import { SubscriptionService } from '@affine/core/modules/cloud';
|
import { SubscriptionService } from '@affine/core/modules/cloud';
|
||||||
import { SubscriptionPlan } from '@affine/graphql';
|
import { SubscriptionPlan } from '@affine/graphql';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -17,7 +16,7 @@ import { nanoid } from 'nanoid';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export interface AIResumeProps extends ButtonProps {
|
export interface AIResumeProps extends ButtonProps {
|
||||||
module: MixpanelEvents['PlanChangeStarted']['module'];
|
module: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AIResume = ({ module, ...btnProps }: AIResumeProps) => {
|
export const AIResume = ({ module, ...btnProps }: AIResumeProps) => {
|
||||||
|
|||||||
@@ -81,10 +81,6 @@ export const RootAppSidebar = (): ReactElement => {
|
|||||||
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
|
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
|
||||||
const onOpenQuickSearchModal = useCallback(() => {
|
const onOpenQuickSearchModal = useCallback(() => {
|
||||||
cmdkQuickSearchService.toggle();
|
cmdkQuickSearchService.toggle();
|
||||||
mixpanel.track('QuickSearchOpened', {
|
|
||||||
segment: 'navigation panel',
|
|
||||||
control: 'search button',
|
|
||||||
});
|
|
||||||
}, [cmdkQuickSearchService]);
|
}, [cmdkQuickSearchService]);
|
||||||
|
|
||||||
const allPageActive = currentPath === '/all';
|
const allPageActive = currentPath === '/all';
|
||||||
@@ -157,6 +153,7 @@ export const RootAppSidebar = (): ReactElement => {
|
|||||||
<QuickSearchInput
|
<QuickSearchInput
|
||||||
className={quickSearch}
|
className={quickSearch}
|
||||||
data-testid="slider-bar-quick-search-button"
|
data-testid="slider-bar-quick-search-button"
|
||||||
|
data-event-props="$.navigationPanel.generalFunction.quickSearch"
|
||||||
onClick={onOpenQuickSearchModal}
|
onClick={onOpenQuickSearchModal}
|
||||||
/>
|
/>
|
||||||
<AddPageButton onClick={onClickNewPage} />
|
<AddPageButton onClick={onClickNewPage} />
|
||||||
|
|||||||
148
packages/frontend/core/src/mixpanel/__tests__/auto.spec.ts
Normal file
148
packages/frontend/core/src/mixpanel/__tests__/auto.spec.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
118
packages/frontend/core/src/mixpanel/auto.ts
Normal file
118
packages/frontend/core/src/mixpanel/auto.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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 load reading 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.quicksearch.changeLanguage' data-event-arg='cn' />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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['event-arg'];
|
||||||
|
} 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
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
interface HTMLAttributes<T> {
|
||||||
|
'data-event-props'?: EventsUnion;
|
||||||
|
'data-event-arg'?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
packages/frontend/core/src/mixpanel/events.ts
Normal file
43
packages/frontend/core/src/mixpanel/events.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// let '$' stands for unspecific matrix
|
||||||
|
/* eslint-disable rxjs/finnish */
|
||||||
|
export interface Events {
|
||||||
|
$: {
|
||||||
|
cmdk: {
|
||||||
|
settings: ['openSettings', 'changeLanguage'];
|
||||||
|
};
|
||||||
|
navigationPanel: {
|
||||||
|
generalFunction: [
|
||||||
|
'quickSearch',
|
||||||
|
'createDoc',
|
||||||
|
'goToAllPage',
|
||||||
|
'goToJournals',
|
||||||
|
'openSettings',
|
||||||
|
];
|
||||||
|
collection: ['createDoc'];
|
||||||
|
bottomButtong: ['downloadApp', 'restartAndInstallUpdate'];
|
||||||
|
others: ['openTrash', 'export'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
doc: {
|
||||||
|
editor: {
|
||||||
|
formatToolbar: ['bold'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
edgeless: {
|
||||||
|
editor: {
|
||||||
|
formatToolbar: ['drawConnector'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// remove when type added
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
allDocs: {};
|
||||||
|
// remove when type added
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
collection: {};
|
||||||
|
// remove when type added
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
tag: {};
|
||||||
|
// remove when type added
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
trash: {};
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { PlanChangeStartedEvent } from './plan-change-started';
|
|
||||||
import type { PlanChangeSucceededEvent } from './plan-change-succeed';
|
|
||||||
|
|
||||||
export interface MixpanelEvents {
|
|
||||||
PlanChangeStarted: PlanChangeStartedEvent;
|
|
||||||
PlanChangeSucceeded: PlanChangeSucceededEvent;
|
|
||||||
OAuth: {
|
|
||||||
provider: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GeneralMixpanelEvent {
|
|
||||||
// location
|
|
||||||
page?: string | null;
|
|
||||||
segment?: string | null;
|
|
||||||
module?: string | null;
|
|
||||||
control?: string | null;
|
|
||||||
|
|
||||||
// entity
|
|
||||||
type?: string | null;
|
|
||||||
category?: string | null;
|
|
||||||
id?: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Before subscription plan changed
|
|
||||||
*/
|
|
||||||
export interface PlanChangeStartedEvent {
|
|
||||||
segment?: 'settings panel';
|
|
||||||
module?: 'pricing plan list' | 'billing subscription list';
|
|
||||||
control?:
|
|
||||||
| 'new subscription' // no subscription before
|
|
||||||
| 'cancel'
|
|
||||||
| 'paying' // resume: subscribed before
|
|
||||||
| 'plan cancel action';
|
|
||||||
type?: SubscriptionPlan;
|
|
||||||
category?: SubscriptionRecurring;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { PlanChangeStartedEvent } from './plan-change-started';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription plan changed successfully
|
|
||||||
*/
|
|
||||||
export type PlanChangeSucceededEvent = Pick<
|
|
||||||
PlanChangeStartedEvent,
|
|
||||||
'control' | 'type' | 'category' | 'segment'
|
|
||||||
>;
|
|
||||||
@@ -1,108 +1,8 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { enableAutoTrack, makeTracker } from './auto';
|
||||||
import type { OverridedMixpanel } from 'mixpanel-browser';
|
import { mixpanel } from './mixpanel';
|
||||||
import mixpanelBrowser from 'mixpanel-browser';
|
|
||||||
|
|
||||||
import type { GeneralMixpanelEvent, MixpanelEvents } from './events';
|
export const track = makeTracker((event, props) => {
|
||||||
|
mixpanel.track(event, props);
|
||||||
|
});
|
||||||
|
|
||||||
const logger = new DebugLogger('mixpanel');
|
export { enableAutoTrack, mixpanel };
|
||||||
|
|
||||||
type Middleware = (
|
|
||||||
name: string,
|
|
||||||
properties?: Record<string, unknown>
|
|
||||||
) => Record<string, unknown>;
|
|
||||||
|
|
||||||
function createMixpanel() {
|
|
||||||
let mixpanel;
|
|
||||||
if (process.env.MIXPANEL_TOKEN) {
|
|
||||||
mixpanelBrowser.init(process.env.MIXPANEL_TOKEN || '', {
|
|
||||||
track_pageview: true,
|
|
||||||
persistence: 'localStorage',
|
|
||||||
api_host: 'https://telemetry.affine.run',
|
|
||||||
});
|
|
||||||
mixpanel = mixpanelBrowser;
|
|
||||||
} else {
|
|
||||||
mixpanel = new Proxy(
|
|
||||||
function () {} as unknown as OverridedMixpanel,
|
|
||||||
createProxyHandler()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const middlewares = new Set<Middleware>();
|
|
||||||
|
|
||||||
const wrapped = {
|
|
||||||
reset() {
|
|
||||||
mixpanel.reset();
|
|
||||||
mixpanel.register({
|
|
||||||
appVersion: runtimeConfig.appVersion,
|
|
||||||
environment: runtimeConfig.appBuildType,
|
|
||||||
editorVersion: runtimeConfig.editorVersion,
|
|
||||||
isSelfHosted: Boolean(runtimeConfig.isSelfHosted),
|
|
||||||
isDesktop: environment.isDesktop,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
track<
|
|
||||||
T extends string,
|
|
||||||
P extends (T extends keyof MixpanelEvents
|
|
||||||
? MixpanelEvents[T]
|
|
||||||
: Record<string, unknown>) &
|
|
||||||
GeneralMixpanelEvent,
|
|
||||||
>(event_name: T, properties?: P) {
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
middleware(cb: Middleware): () => void {
|
|
||||||
middlewares.add(cb);
|
|
||||||
return () => {
|
|
||||||
middlewares.delete(cb);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
opt_out_tracking() {
|
|
||||||
mixpanel.opt_out_tracking();
|
|
||||||
},
|
|
||||||
opt_in_tracking() {
|
|
||||||
mixpanel.opt_in_tracking();
|
|
||||||
},
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
get people() {
|
|
||||||
return mixpanel.people;
|
|
||||||
},
|
|
||||||
track_pageview(properties?: { location?: string }) {
|
|
||||||
logger.debug('track_pageview', properties);
|
|
||||||
mixpanel.track_pageview(properties);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
wrapped.reset();
|
|
||||||
|
|
||||||
return wrapped;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mixpanel = createMixpanel();
|
|
||||||
|
|
||||||
function createProxyHandler() {
|
|
||||||
const handler = {
|
|
||||||
get: () => {
|
|
||||||
return new Proxy(
|
|
||||||
function () {} as unknown as OverridedMixpanel,
|
|
||||||
createProxyHandler()
|
|
||||||
);
|
|
||||||
},
|
|
||||||
apply: () => {},
|
|
||||||
} as ProxyHandler<OverridedMixpanel>;
|
|
||||||
return handler;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports
|
|
||||||
import * as mixpanel from 'mixpanel-browser';
|
|
||||||
|
|
||||||
import type { GeneralMixpanelEvent, MixpanelEvents } from './events';
|
|
||||||
|
|
||||||
declare module 'mixpanel-browser' {
|
|
||||||
export interface OverridedMixpanel {
|
|
||||||
track<
|
|
||||||
T extends string,
|
|
||||||
P extends (T extends keyof MixpanelEvents
|
|
||||||
? MixpanelEvents[T]
|
|
||||||
: Record<string, unknown>) &
|
|
||||||
GeneralMixpanelEvent,
|
|
||||||
>(
|
|
||||||
event_name: T,
|
|
||||||
properties?: P
|
|
||||||
): void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
101
packages/frontend/core/src/mixpanel/mixpanel.ts
Normal file
101
packages/frontend/core/src/mixpanel/mixpanel.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports
|
||||||
|
import { DebugLogger } from '@affine/debug';
|
||||||
|
import type { OverridedMixpanel } from 'mixpanel-browser';
|
||||||
|
import mixpanelBrowser from 'mixpanel-browser';
|
||||||
|
|
||||||
|
const logger = new DebugLogger('mixpanel');
|
||||||
|
|
||||||
|
type Middleware = (
|
||||||
|
name: string,
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
) => Record<string, unknown>;
|
||||||
|
|
||||||
|
function createMixpanel() {
|
||||||
|
let mixpanel;
|
||||||
|
if (process.env.MIXPANEL_TOKEN) {
|
||||||
|
mixpanelBrowser.init(process.env.MIXPANEL_TOKEN || '', {
|
||||||
|
track_pageview: true,
|
||||||
|
persistence: 'localStorage',
|
||||||
|
api_host: 'https://telemetry.affine.run',
|
||||||
|
});
|
||||||
|
mixpanel = mixpanelBrowser;
|
||||||
|
} else {
|
||||||
|
mixpanel = new Proxy(
|
||||||
|
function () {} as unknown as OverridedMixpanel,
|
||||||
|
createProxyHandler()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const middlewares = new Set<Middleware>();
|
||||||
|
|
||||||
|
const wrapped = {
|
||||||
|
reset() {
|
||||||
|
mixpanel.reset();
|
||||||
|
mixpanel.register({
|
||||||
|
appVersion: runtimeConfig.appVersion,
|
||||||
|
environment: runtimeConfig.appBuildType,
|
||||||
|
editorVersion: runtimeConfig.editorVersion,
|
||||||
|
isSelfHosted: Boolean(runtimeConfig.isSelfHosted),
|
||||||
|
isDesktop: environment.isDesktop,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
middleware(cb: Middleware): () => void {
|
||||||
|
middlewares.add(cb);
|
||||||
|
return () => {
|
||||||
|
middlewares.delete(cb);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
opt_out_tracking() {
|
||||||
|
mixpanel.opt_out_tracking();
|
||||||
|
},
|
||||||
|
opt_in_tracking() {
|
||||||
|
mixpanel.opt_in_tracking();
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
get people() {
|
||||||
|
return mixpanel.people;
|
||||||
|
},
|
||||||
|
track_pageview(properties?: { location?: string }) {
|
||||||
|
logger.debug('track_pageview', properties);
|
||||||
|
mixpanel.track_pageview(properties);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
wrapped.reset();
|
||||||
|
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mixpanel = createMixpanel();
|
||||||
|
|
||||||
|
function createProxyHandler() {
|
||||||
|
const handler = {
|
||||||
|
get: () => {
|
||||||
|
return new Proxy(
|
||||||
|
function () {} as unknown as OverridedMixpanel,
|
||||||
|
createProxyHandler()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
apply: () => {},
|
||||||
|
} as ProxyHandler<OverridedMixpanel>;
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
60
packages/frontend/core/src/mixpanel/types.ts
Normal file
60
packages/frontend/core/src/mixpanel/types.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Events } from './events';
|
||||||
|
|
||||||
|
export type CallableEventsChain = {
|
||||||
|
[Page in keyof Events]: {
|
||||||
|
[Segment in keyof Events[Page]]: {
|
||||||
|
[Module in keyof Events[Page][Segment]]: {
|
||||||
|
// @ts-expect-error ignore `symbol | number` as key
|
||||||
|
[Control in Events[Page][Segment][Module][number]]: (
|
||||||
|
arg?: string
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventsUnion = {
|
||||||
|
[Page in keyof Events]: {
|
||||||
|
[Segment in keyof Events[Page]]: {
|
||||||
|
[Module in keyof Events[Page][Segment]]: {
|
||||||
|
// @ts-expect-error ignore `symbol | number` as key
|
||||||
|
[Control in Events[Page][Segment][Module][number]]: `${Page}.${Segment}.${Module}.${Control}`;
|
||||||
|
// @ts-expect-error ignore `symbol | number` as key
|
||||||
|
}[Events[Page][Segment][Module][number]];
|
||||||
|
}[keyof Events[Page][Segment]];
|
||||||
|
}[keyof Events[Page]];
|
||||||
|
}[keyof Events];
|
||||||
|
|
||||||
|
// page > segment > module > [controls]
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { mixpanel } from '@affine/core/mixpanel';
|
import { mixpanel } from '@affine/core/mixpanel';
|
||||||
|
import type { EventProps } from '@affine/core/mixpanel/types';
|
||||||
import type { QuotaQuery } from '@affine/graphql';
|
import type { QuotaQuery } from '@affine/graphql';
|
||||||
import type { GlobalContextService } from '@toeverything/infra';
|
import type { GlobalContextService } from '@toeverything/infra';
|
||||||
import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra';
|
import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra';
|
||||||
@@ -72,24 +73,24 @@ export class TelemetryService extends Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
extractGlobalContext() {
|
extractGlobalContext(): EventProps {
|
||||||
const globalContext = this.globalContextService.globalContext;
|
const globalContext = this.globalContextService.globalContext;
|
||||||
const page = globalContext.isDoc.get()
|
const page = globalContext.isDoc.get()
|
||||||
? globalContext.isTrashDoc.get()
|
? globalContext.isTrashDoc.get()
|
||||||
? 'trash'
|
? 'trash'
|
||||||
: globalContext.docMode.get() === 'page'
|
: globalContext.docMode.get() === 'page'
|
||||||
? 'doc editor'
|
? 'doc'
|
||||||
: 'whiteboard editor'
|
: 'edgeless'
|
||||||
: globalContext.isAllDocs.get()
|
: globalContext.isAllDocs.get()
|
||||||
? 'doc library'
|
? 'allDocs'
|
||||||
: globalContext.isTrash.get()
|
: globalContext.isTrash.get()
|
||||||
? 'trash library'
|
? 'trash'
|
||||||
: globalContext.isCollection.get()
|
: globalContext.isCollection.get()
|
||||||
? 'collection detail'
|
? 'collection'
|
||||||
: globalContext.isTag.get()
|
: globalContext.isTag.get()
|
||||||
? 'tag detail'
|
? 'tag'
|
||||||
: 'unknown';
|
: undefined;
|
||||||
return { page, activePage: page };
|
return { page };
|
||||||
}
|
}
|
||||||
|
|
||||||
override dispose(): void {
|
override dispose(): void {
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { appSettingAtom } from '@toeverything/infra';
|
|||||||
import { useAtomValue } from 'jotai/react';
|
import { useAtomValue } from 'jotai/react';
|
||||||
import { useLayoutEffect } from 'react';
|
import { useLayoutEffect } from 'react';
|
||||||
|
|
||||||
import { mixpanel } from './mixpanel';
|
import { enableAutoTrack, mixpanel } from './mixpanel';
|
||||||
|
|
||||||
export function Telemetry() {
|
export function Telemetry() {
|
||||||
const settings = useAtomValue(appSettingAtom);
|
const settings = useAtomValue(appSettingAtom);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (settings.enableTelemetry === false) {
|
if (settings.enableTelemetry === false) {
|
||||||
mixpanel.opt_out_tracking();
|
mixpanel.opt_out_tracking();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return enableAutoTrack(document.body, mixpanel.track);
|
||||||
}
|
}
|
||||||
}, [settings.enableTelemetry]);
|
}, [settings.enableTelemetry]);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user