diff --git a/packages/frontend/core/src/commands/affine-navigation.tsx b/packages/frontend/core/src/commands/affine-navigation.tsx index 106bdb8fa8c20..026a8a7079ac9 100644 --- a/packages/frontend/core/src/commands/affine-navigation.tsx +++ b/packages/frontend/core/src/commands/affine-navigation.tsx @@ -6,7 +6,7 @@ import type { createStore } from 'jotai'; import { openSettingModalAtom, openWorkspaceListModalAtom } from '../atoms'; import type { useNavigateHelper } from '../hooks/use-navigate-helper'; -import { mixpanel } from '../mixpanel'; +import { mixpanel, track } from '../mixpanel'; import { registerAffineCommand } from './registry'; export function registerAffineNavigationCommands({ @@ -77,10 +77,7 @@ export function registerAffineNavigationCommands({ label: t['com.affine.cmdk.affine.navigation.open-settings'](), keyBinding: '$mod+,', run() { - mixpanel.track('SettingsViewed', { - // page: - segment: 'cmdk', - }); + track.$.cmdk.settings.openSettings(); store.set(openSettingModalAtom, s => ({ activeTab: 'appearance', open: !s.open, diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx index 2f14c3c00d6c4..1dfbb5c885fdf 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx @@ -1,7 +1,6 @@ import { getDowngradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { mixpanel } from '@affine/core/mixpanel'; -import type { MixpanelEvents } from '@affine/core/mixpanel/events'; import { SubscriptionPlan } from '@affine/graphql'; import { useLiveData, useService } from '@toeverything/infra'; import { nanoid } from 'nanoid'; @@ -25,7 +24,7 @@ export const CancelAction = ({ }: { open: boolean; onOpenChange: (open: boolean) => void; - module: MixpanelEvents['PlanChangeStarted']['module']; + module: string; } & PropsWithChildren) => { const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); const [isMutating, setIsMutating] = useState(false); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx index 50c9ffb80db29..a0652f836f42f 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx @@ -3,7 +3,6 @@ import { useDowngradeNotify } from '@affine/core/components/affine/subscription- import { getDowngradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { mixpanel } from '@affine/core/mixpanel'; -import type { MixpanelEvents } from '@affine/core/mixpanel/events'; import { AuthService, SubscriptionService } from '@affine/core/modules/cloud'; import { SubscriptionPlan } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; @@ -12,7 +11,7 @@ import { nanoid } from 'nanoid'; import { useState } from 'react'; export interface AICancelProps extends ButtonProps { - module: MixpanelEvents['PlanChangeStarted']['module']; + module: string; } export const AICancel = ({ module, ...btnProps }: AICancelProps) => { const t = useI18n(); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx index b5813bd18fe9e..b84cad8d05943 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx @@ -6,7 +6,6 @@ import { } from '@affine/component'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { mixpanel } from '@affine/core/mixpanel'; -import type { MixpanelEvents } from '@affine/core/mixpanel/events'; import { SubscriptionService } from '@affine/core/modules/cloud'; import { SubscriptionPlan } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; @@ -17,7 +16,7 @@ import { nanoid } from 'nanoid'; import { useState } from 'react'; export interface AIResumeProps extends ButtonProps { - module: MixpanelEvents['PlanChangeStarted']['module']; + module: string; } export const AIResume = ({ module, ...btnProps }: AIResumeProps) => { diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index 1e0d0599b2f20..5c740525c58bb 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -81,10 +81,6 @@ export const RootAppSidebar = (): ReactElement => { const cmdkQuickSearchService = useService(CMDKQuickSearchService); const onOpenQuickSearchModal = useCallback(() => { cmdkQuickSearchService.toggle(); - mixpanel.track('QuickSearchOpened', { - segment: 'navigation panel', - control: 'search button', - }); }, [cmdkQuickSearchService]); const allPageActive = currentPath === '/all'; @@ -157,6 +153,7 @@ export const RootAppSidebar = (): ReactElement => { diff --git a/packages/frontend/core/src/mixpanel/__tests__/auto.spec.ts b/packages/frontend/core/src/mixpanel/__tests__/auto.spec.ts new file mode 100644 index 0000000000000..3fdb241a8630c --- /dev/null +++ b/packages/frontend/core/src/mixpanel/__tests__/auto.spec.ts @@ -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', + }); + }); +}); diff --git a/packages/frontend/core/src/mixpanel/auto.ts b/packages/frontend/core/src/mixpanel/auto.ts new file mode 100644 index 0000000000000..ea63313331ee3 --- /dev/null +++ b/packages/frontend/core/src/mixpanel/auto.ts @@ -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): void; +} + +const levels = ['page', 'segment', 'module', 'event'] as const; +export function makeTracker(trackFn: TrackFn): CallableEventsChain { + function makeTrackerInner(level: number, info: Record) { + const proxy = new Proxy({} as Record, { + 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) => { + 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 + *