diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx
index e08ece6666..30d8f3e410 100644
--- a/web/app/components/app-initializer.tsx
+++ b/web/app/components/app-initializer.tsx
@@ -9,6 +9,7 @@ import {
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
+import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
@@ -45,6 +46,8 @@ export const AppInitializer = ({
(async () => {
const action = searchParams.get('action')
+ rememberCreateAppExternalAttribution({ searchParams })
+
if (oauthNewUser) {
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx
index 3ebc5f7157..486bb98ac1 100644
--- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx
+++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx
@@ -4,7 +4,6 @@ import { AppModeEnum } from '@/types/app'
import Apps from '../index'
const mockUseExploreAppList = vi.fn()
-const mockTrackEvent = vi.fn()
const mockImportDSL = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockHandleCheckPluginDependencies = vi.fn()
@@ -12,6 +11,7 @@ const mockGetRedirection = vi.fn()
const mockPush = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
+const mockTrackCreateApp = vi.fn()
let latestDebounceFn = () => {}
vi.mock('ahooks', () => ({
@@ -92,8 +92,8 @@ vi.mock('@/app/components/base/ui/toast', () => ({
error: (...args: unknown[]) => mockToastError(...args),
},
}))
-vi.mock('@/app/components/base/amplitude', () => ({
- trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
+vi.mock('@/utils/create-app-tracking', () => ({
+ trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
}))
vi.mock('@/service/apps', () => ({
importDSL: (...args: unknown[]) => mockImportDSL(...args),
@@ -246,10 +246,10 @@ describe('Apps', () => {
}))
})
- expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_template', expect.objectContaining({
- template_id: 'Alpha',
- template_name: 'Alpha',
- }))
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'studio_template_list',
+ templateId: 'Alpha',
+ })
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
expect(onSuccess).toHaveBeenCalled()
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('created-app-id')
diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx
index 1aa40d2014..1f8e34be71 100644
--- a/web/app/components/app/create-app-dialog/app-list/index.tsx
+++ b/web/app/components/app/create-app-dialog/app-list/index.tsx
@@ -8,7 +8,6 @@ import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppTypeSelector from '@/app/components/app/type-selector'
-import { trackEvent } from '@/app/components/base/amplitude'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
@@ -25,6 +24,7 @@ import { useExploreAppList } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
+import { trackCreateApp } from '@/utils/create-app-tracking'
import AppCard from '../app-card'
import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar'
@@ -127,14 +127,8 @@ const Apps = ({
icon_background,
description,
})
-
- // Track app creation from template
- trackEvent('create_app_with_template', {
- app_mode: mode,
- template_id: currApp?.app.id,
- template_name: currApp?.app.name,
- description,
- })
+ if (currApp?.app.id)
+ trackCreateApp({ source: 'studio_template_list', templateId: currApp.app.id })
setIsShowCreateModal(false)
toast.success(t('newApp.appCreated', { ns: 'app' }))
diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx
index ee24ab4006..8724778777 100644
--- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx
+++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx
@@ -1,7 +1,6 @@
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
-import { trackEvent } from '@/app/components/base/amplitude'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
@@ -10,6 +9,7 @@ import { useRouter } from '@/next/navigation'
import { createApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
+import { trackCreateApp } from '@/utils/create-app-tracking'
import CreateAppModal from '../index'
const ahooksMocks = vi.hoisted(() => ({
@@ -31,8 +31,8 @@ vi.mock('ahooks', () => ({
vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(),
}))
-vi.mock('@/app/components/base/amplitude', () => ({
- trackEvent: vi.fn(),
+vi.mock('@/utils/create-app-tracking', () => ({
+ trackCreateApp: vi.fn(),
}))
vi.mock('@/service/apps', () => ({
createApp: vi.fn(),
@@ -87,7 +87,7 @@ vi.mock('@/hooks/use-theme', () => ({
const mockUseRouter = vi.mocked(useRouter)
const mockPush = vi.fn()
const mockCreateApp = vi.mocked(createApp)
-const mockTrackEvent = vi.mocked(trackEvent)
+const mockTrackCreateApp = vi.mocked(trackCreateApp)
const mockGetRedirection = vi.mocked(getRedirection)
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseAppContext = vi.mocked(useAppContext)
@@ -178,10 +178,7 @@ describe('CreateAppModal', () => {
mode: AppModeEnum.ADVANCED_CHAT,
}))
- expect(mockTrackEvent).toHaveBeenCalledWith('create_app', {
- app_mode: AppModeEnum.ADVANCED_CHAT,
- description: '',
- })
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_blank' })
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
expect(onSuccess).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx
index f2ced9b6c0..7bcaff7a31 100644
--- a/web/app/components/app/create-app-modal/index.tsx
+++ b/web/app/components/app/create-app-modal/index.tsx
@@ -6,7 +6,6 @@ import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon
import { useDebounceFn, useKeyPress } from 'ahooks'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { trackEvent } from '@/app/components/base/amplitude'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
@@ -25,6 +24,7 @@ import { createApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
+import { trackCreateApp } from '@/utils/create-app-tracking'
import { basePath } from '@/utils/var'
import AppIconPicker from '../../base/app-icon-picker'
import ShortcutsName from '../../workflow/shortcuts-name'
@@ -80,11 +80,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
mode: appMode,
})
- // Track app creation success
- trackEvent('create_app', {
- app_mode: appMode,
- description,
- })
+ trackCreateApp({ source: 'studio_blank' })
toast.success(t('newApp.appCreated', { ns: 'app' }))
onSuccess()
diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx
index c1ffbc22e8..4a1ef74450 100644
--- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx
+++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx
@@ -7,7 +7,7 @@ import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
const mockPush = vi.fn()
const mockImportDSL = vi.fn()
const mockImportDSLConfirm = vi.fn()
-const mockTrackEvent = vi.fn()
+const mockTrackCreateApp = vi.fn()
const mockHandleCheckPluginDependencies = vi.fn()
const mockGetRedirection = vi.fn()
const toastMocks = vi.hoisted(() => ({
@@ -43,8 +43,8 @@ vi.mock('@/next/navigation', () => ({
}),
}))
-vi.mock('@/app/components/base/amplitude', () => ({
- trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
+vi.mock('@/utils/create-app-tracking', () => ({
+ trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
}))
vi.mock('@/service/apps', () => ({
@@ -196,10 +196,7 @@ describe('CreateFromDSLModal', () => {
mode: DSLImportMode.YAML_URL,
yaml_url: 'https://example.com/app.yml',
})
- expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_dsl', expect.objectContaining({
- creation_method: 'dsl_url',
- has_warnings: false,
- }))
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload' })
expect(handleSuccess).toHaveBeenCalledTimes(1)
expect(handleClose).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1')
@@ -305,6 +302,7 @@ describe('CreateFromDSLModal', () => {
expect(mockImportDSLConfirm).toHaveBeenCalledWith({
import_id: 'import-3',
})
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload' })
})
it('should ignore empty import responses and prevent duplicate submissions while a request is in flight', async () => {
diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx
index dd17655e3c..b31911d9e2 100644
--- a/web/app/components/app/create-from-dsl-modal/index.tsx
+++ b/web/app/components/app/create-from-dsl-modal/index.tsx
@@ -6,7 +6,6 @@ import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
@@ -27,6 +26,7 @@ import {
} from '@/service/apps'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
+import { trackCreateApp } from '@/utils/create-app-tracking'
import ShortcutsName from '../../workflow/shortcuts-name'
import Uploader from './uploader'
@@ -112,12 +112,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
return
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
- // Track app creation from DSL import
- trackEvent('create_app_with_dsl', {
- app_mode,
- creation_method: currentTab === CreateFromDSLModalTab.FROM_FILE ? 'dsl_file' : 'dsl_url',
- has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS,
- })
+ trackCreateApp({ source: 'studio_upload' })
if (onSuccess)
onSuccess()
@@ -179,6 +174,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
const { status, app_id, app_mode } = response
if (status === DSLImportStatus.COMPLETED) {
+ trackCreateApp({ source: 'studio_upload' })
if (onSuccess)
onSuccess()
if (onClose)
@@ -228,7 +224,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
isShow={show}
onClose={noop}
>
-
+
{t('importFromDSL', { ns: 'app' })}
-
+
{
tabs.map(tab => (
-
DSL URL
+
DSL URL
-
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
-
+
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
+
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx
index da4fbc2d44..c6a552fa73 100644
--- a/web/app/components/apps/__tests__/index.spec.tsx
+++ b/web/app/components/apps/__tests__/index.spec.tsx
@@ -1,12 +1,48 @@
import type { ReactNode } from 'react'
+import type { App } from '@/models/explore'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { render, screen } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
+import { useContextSelector } from 'use-context-selector'
+import AppListContext from '@/context/app-list-context'
+import { fetchAppDetail } from '@/service/explore'
+import { AppModeEnum } from '@/types/app'
import Apps from '../index'
let documentTitleCalls: string[] = []
let educationInitCalls: number = 0
+const mockHandleImportDSL = vi.fn()
+const mockHandleImportDSLConfirm = vi.fn()
+const mockTrackCreateApp = vi.fn()
+const mockFetchAppDetail = vi.mocked(fetchAppDetail)
+
+const mockTemplateApp: App = {
+ app_id: 'template-1',
+ category: 'Assistant',
+ app: {
+ id: 'template-1',
+ mode: AppModeEnum.CHAT,
+ icon_type: 'emoji',
+ icon: '🤖',
+ icon_background: '#fff',
+ icon_url: '',
+ name: 'Sample App',
+ description: 'Sample App',
+ use_icon_as_answer_icon: false,
+ },
+ description: 'Sample App',
+ can_trial: true,
+ copyright: '',
+ privacy_policy: null,
+ custom_disclaimer: null,
+ position: 1,
+ is_listed: true,
+ install_count: 0,
+ installed: false,
+ editable: false,
+ is_agent: false,
+}
vi.mock('@/hooks/use-document-title', () => ({
default: (title: string) => {
@@ -22,17 +58,80 @@ vi.mock('@/app/education-apply/hooks', () => ({
vi.mock('@/hooks/use-import-dsl', () => ({
useImportDSL: () => ({
- handleImportDSL: vi.fn(),
- handleImportDSLConfirm: vi.fn(),
+ handleImportDSL: mockHandleImportDSL,
+ handleImportDSLConfirm: mockHandleImportDSLConfirm,
versions: [],
isFetching: false,
}),
}))
-vi.mock('../list', () => ({
- default: () => {
- return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List')
- },
+vi.mock('../list', () => {
+ const MockList = () => {
+ const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel)
+ return React.createElement(
+ 'div',
+ { 'data-testid': 'apps-list' },
+ React.createElement('span', null, 'Apps List'),
+ React.createElement(
+ 'button',
+ {
+ 'data-testid': 'open-preview',
+ 'onClick': () => setShowTryAppPanel(true, {
+ appId: mockTemplateApp.app_id,
+ app: mockTemplateApp,
+ }),
+ },
+ 'Open Preview',
+ ),
+ )
+ }
+
+ return { default: MockList }
+})
+
+vi.mock('../../explore/try-app', () => ({
+ default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => (
+
+
+
+
+ ),
+}))
+
+vi.mock('../../explore/create-app-modal', () => ({
+ default: ({ show, onConfirm, onHide }: { show: boolean, onConfirm: (payload: Record
) => Promise, onHide: () => void }) => show
+ ? (
+
+
+
+
+ )
+ : null,
+}))
+
+vi.mock('../../app/create-from-dsl-modal/dsl-confirm-modal', () => ({
+ default: ({ onConfirm }: { onConfirm: () => void }) => (
+
+ ),
+}))
+
+vi.mock('@/service/explore', () => ({
+ fetchAppDetail: vi.fn(),
+}))
+
+vi.mock('@/utils/create-app-tracking', () => ({
+ trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
}))
describe('Apps', () => {
@@ -59,6 +158,14 @@ describe('Apps', () => {
vi.clearAllMocks()
documentTitleCalls = []
educationInitCalls = 0
+ mockFetchAppDetail.mockResolvedValue({
+ id: 'template-1',
+ name: 'Sample App',
+ icon: '🤖',
+ icon_background: '#fff',
+ mode: AppModeEnum.CHAT,
+ export_data: 'yaml-content',
+ })
})
describe('Rendering', () => {
@@ -116,6 +223,26 @@ describe('Apps', () => {
)
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
})
+
+ it('should track template preview creation after a successful import', async () => {
+ mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
+ options.onSuccess?.()
+ })
+
+ renderWithClient()
+
+ fireEvent.click(screen.getByTestId('open-preview'))
+ fireEvent.click(await screen.findByTestId('try-app-create'))
+ fireEvent.click(await screen.findByTestId('confirm-create'))
+
+ await waitFor(() => {
+ expect(mockFetchAppDetail).toHaveBeenCalledWith('template-1')
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'studio_template_preview',
+ templateId: 'template-1',
+ })
+ })
+ })
})
describe('Styling', () => {
diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx
index b6ca60bd7b..1bc825306a 100644
--- a/web/app/components/apps/index.tsx
+++ b/web/app/components/apps/index.tsx
@@ -10,6 +10,7 @@ import { useImportDSL } from '@/hooks/use-import-dsl'
import { DSLImportMode } from '@/models/app'
import dynamic from '@/next/dynamic'
import { fetchAppDetail } from '@/service/explore'
+import { trackCreateApp } from '@/utils/create-app-tracking'
import List from './list'
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
@@ -40,6 +41,13 @@ const Apps = () => {
const handleShowFromTryApp = useCallback(() => {
setIsShowCreateModal(true)
}, [])
+ const trackCurrentCreateApp = useCallback(() => {
+ const templateId = currApp?.app.id
+ if (!templateId)
+ return
+
+ trackCreateApp({ source: 'studio_template_preview', templateId })
+ }, [currApp?.app.id])
const [controlRefreshList, setControlRefreshList] = useState(0)
const [controlHideCreateFromTemplatePanel, setControlHideCreateFromTemplatePanel] = useState(0)
@@ -59,11 +67,14 @@ const Apps = () => {
const onConfirmDSL = useCallback(async () => {
await handleImportDSLConfirm({
- onSuccess,
+ onSuccess: () => {
+ trackCurrentCreateApp()
+ onSuccess()
+ },
})
- }, [handleImportDSLConfirm, onSuccess])
+ }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
- const onCreate: CreateAppModalProps['onConfirm'] = async ({
+ const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
icon,
@@ -86,13 +97,14 @@ const Apps = () => {
}
await handleImportDSL(payload, {
onSuccess: () => {
+ trackCurrentCreateApp()
setIsShowCreateModal(false)
},
onPending: () => {
setShowDSLConfirmModal(true)
},
})
- }
+ }, [currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp])
return (
({
useExploreAppList: () => ({
@@ -45,6 +46,9 @@ vi.mock('@/hooks/use-import-dsl', () => ({
isFetching: false,
}),
}))
+vi.mock('@/utils/create-app-tracking', () => ({
+ trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
+}))
vi.mock('@/app/components/explore/create-app-modal', () => ({
default: (props: CreateAppModalProps) => {
@@ -235,6 +239,10 @@ describe('AppList', () => {
fireEvent.click(screen.getByTestId('dsl-confirm'))
await waitFor(() => {
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'explore_template_list',
+ templateId: 'app-basic-id',
+ })
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})
@@ -337,6 +345,10 @@ describe('AppList', () => {
await waitFor(() => {
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'explore_template_list',
+ templateId: 'app-basic-id',
+ })
})
it('should cancel DSL confirm modal', async () => {
@@ -385,6 +397,31 @@ describe('AppList', () => {
})
})
+ it('should track preview source when creation starts from try app details', async () => {
+ vi.useRealTimers()
+ mockExploreData = {
+ categories: ['Writing'],
+ allList: [createApp()],
+ };
+ (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' })
+ mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
+ options.onSuccess?.()
+ })
+
+ renderAppList(true)
+
+ fireEvent.click(screen.getByText('explore.appCard.try'))
+ fireEvent.click(screen.getByTestId('try-app-create'))
+ fireEvent.click(await screen.findByTestId('confirm-create'))
+
+ await waitFor(() => {
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'explore_template_preview',
+ templateId: 'app-basic-id',
+ })
+ })
+ })
+
it('should close try app panel when close is clicked', () => {
mockExploreData = {
categories: ['Writing'],
diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx
index d508f141b4..7eaef06f11 100644
--- a/web/app/components/explore/app-list/index.tsx
+++ b/web/app/components/explore/app-list/index.tsx
@@ -26,6 +26,7 @@ import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { useExploreAppList } from '@/service/use-explore'
import { cn } from '@/utils/classnames'
+import { trackCreateApp } from '@/utils/create-app-tracking'
import TryApp from '../try-app'
import s from './style.module.css'
@@ -91,6 +92,7 @@ const Apps = ({
const [currApp, setCurrApp] = useState(null)
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
+ const [createAppSource, setCreateAppSource] = useState<'explore_template_list' | 'explore_template_preview'>('explore_template_list')
const {
handleImportDSL,
@@ -110,10 +112,18 @@ const Apps = ({
}, [])
const handleShowFromTryApp = useCallback(() => {
setCurrApp(currentTryApp?.app || null)
+ setCreateAppSource('explore_template_preview')
setIsShowCreateModal(true)
}, [currentTryApp?.app])
+ const trackCurrentCreateApp = useCallback(() => {
+ const templateId = currApp?.app.id
+ if (!templateId)
+ return
- const onCreate: CreateAppModalProps['onConfirm'] = async ({
+ trackCreateApp({ source: createAppSource, templateId })
+ }, [createAppSource, currApp?.app.id])
+
+ const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
icon,
@@ -136,19 +146,23 @@ const Apps = ({
}
await handleImportDSL(payload, {
onSuccess: () => {
+ trackCurrentCreateApp()
setIsShowCreateModal(false)
},
onPending: () => {
setShowDSLConfirmModal(true)
},
})
- }
+ }, [currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp])
const onConfirmDSL = useCallback(async () => {
await handleImportDSLConfirm({
- onSuccess,
+ onSuccess: () => {
+ trackCurrentCreateApp()
+ onSuccess?.()
+ },
})
- }, [handleImportDSLConfirm, onSuccess])
+ }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
if (isLoading) {
return (
@@ -226,6 +240,7 @@ const Apps = ({
canCreate={hasEditPermission}
onCreate={() => {
setCurrApp(app)
+ setCreateAppSource('explore_template_list')
setIsShowCreateModal(true)
}}
onTry={handleTryApp}
diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx
index 39c15e6161..2da6c670ad 100644
--- a/web/app/signup/set-password/page.tsx
+++ b/web/app/signup/set-password/page.tsx
@@ -11,6 +11,7 @@ import { validPassword } from '@/config'
import { useRouter, useSearchParams } from '@/next/navigation'
import { useMailRegister } from '@/service/use-common'
import { cn } from '@/utils/classnames'
+import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking'
import { sendGAEvent } from '@/utils/gtag'
const parseUtmInfo = () => {
@@ -68,6 +69,7 @@ const ChangePasswordForm = () => {
const { result } = res as MailRegisterResponse
if (result === 'success') {
const utmInfo = parseUtmInfo()
+ rememberCreateAppExternalAttribution({ utmInfo })
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'email',
...utmInfo,
diff --git a/web/utils/__tests__/create-app-tracking.spec.ts b/web/utils/__tests__/create-app-tracking.spec.ts
new file mode 100644
index 0000000000..778a062a60
--- /dev/null
+++ b/web/utils/__tests__/create-app-tracking.spec.ts
@@ -0,0 +1,94 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import * as amplitude from '@/app/components/base/amplitude'
+import {
+ buildCreateAppEventPayload,
+ extractExternalCreateAppAttribution,
+ rememberCreateAppExternalAttribution,
+ trackCreateApp,
+} from '../create-app-tracking'
+
+describe('create-app-tracking', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {})
+ window.sessionStorage.clear()
+ window.history.replaceState({}, '', '/apps')
+ })
+
+ describe('extractExternalCreateAppAttribution', () => {
+ it('should map campaign links to external attribution', () => {
+ const attribution = extractExternalCreateAppAttribution({
+ searchParams: new URLSearchParams('utm_source=x&slug=how-to-build-rag-agent'),
+ })
+
+ expect(attribution).toEqual({
+ utmSource: 'twitter/x',
+ utmCampaign: 'how-to-build-rag-agent',
+ })
+ })
+
+ it('should map newsletter and blog sources to blog', () => {
+ expect(extractExternalCreateAppAttribution({
+ searchParams: new URLSearchParams('utm_source=newsletter'),
+ })).toEqual({ utmSource: 'blog' })
+
+ expect(extractExternalCreateAppAttribution({
+ utmInfo: { utm_source: 'dify_blog', slug: 'launch-week' },
+ })).toEqual({
+ utmSource: 'blog',
+ utmCampaign: 'launch-week',
+ })
+ })
+ })
+
+ describe('buildCreateAppEventPayload', () => {
+ it('should build template payloads with template id', () => {
+ expect(buildCreateAppEventPayload({
+ source: 'explore_template_preview',
+ templateId: 'template-1',
+ })).toEqual({
+ source: 'explore_template_preview',
+ template_id: 'template-1',
+ })
+ })
+
+ it('should prefer external attribution when present', () => {
+ expect(buildCreateAppEventPayload(
+ {
+ source: 'studio_template_list',
+ templateId: 'template-2',
+ },
+ {
+ utmSource: 'linkedin',
+ utmCampaign: 'agent-launch',
+ },
+ )).toEqual({
+ source: 'external',
+ utm_source: 'linkedin',
+ utm_campaign: 'agent-launch',
+ })
+ })
+ })
+
+ describe('trackCreateApp', () => {
+ it('should track remembered external attribution once before falling back to internal source', () => {
+ rememberCreateAppExternalAttribution({
+ searchParams: new URLSearchParams('utm_source=newsletter&slug=how-to-build-rag-agent'),
+ })
+
+ trackCreateApp({ source: 'studio_blank' })
+
+ expect(amplitude.trackEvent).toHaveBeenNthCalledWith(1, 'create_app', {
+ source: 'external',
+ utm_source: 'blog',
+ utm_campaign: 'how-to-build-rag-agent',
+ })
+
+ trackCreateApp({ source: 'studio_blank' })
+
+ expect(amplitude.trackEvent).toHaveBeenNthCalledWith(2, 'create_app', {
+ source: 'studio_blank',
+ })
+ })
+ })
+})
diff --git a/web/utils/create-app-tracking.ts b/web/utils/create-app-tracking.ts
new file mode 100644
index 0000000000..b2d377a154
--- /dev/null
+++ b/web/utils/create-app-tracking.ts
@@ -0,0 +1,176 @@
+import Cookies from 'js-cookie'
+import { trackEvent } from '@/app/components/base/amplitude'
+
+const CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY = 'create_app_external_attribution'
+
+const EXTERNAL_UTM_SOURCE_MAP = {
+ blog: 'blog',
+ dify_blog: 'blog',
+ linkedin: 'linkedin',
+ newsletter: 'blog',
+ twitter: 'twitter/x',
+ x: 'twitter/x',
+} as const
+
+type SearchParamReader = {
+ get: (name: string) => string | null
+}
+
+type CreateAppSource = 'external' | 'explore_template_list' | 'explore_template_preview' | 'studio_blank' | 'studio_template_list' | 'studio_template_preview' | 'studio_upload'
+
+type TemplateCreateAppSource = Extract
+
+type NonTemplateCreateAppSource = Extract
+
+type TrackCreateAppParams = { source: TemplateCreateAppSource, templateId: string } | { source: NonTemplateCreateAppSource }
+
+type ExternalCreateAppAttribution = {
+ utmSource: typeof EXTERNAL_UTM_SOURCE_MAP[keyof typeof EXTERNAL_UTM_SOURCE_MAP]
+ utmCampaign?: string
+}
+
+const normalizeString = (value?: string | null) => {
+ const trimmed = value?.trim()
+ return trimmed || undefined
+}
+
+const getObjectStringValue = (value: unknown) => {
+ return typeof value === 'string' ? normalizeString(value) : undefined
+}
+
+const getSearchParamValue = (searchParams?: SearchParamReader | null, key?: string) => {
+ if (!searchParams || !key)
+ return undefined
+ return normalizeString(searchParams.get(key))
+}
+
+const parseJSONRecord = (value?: string | null): Record | null => {
+ if (!value)
+ return null
+
+ try {
+ const parsed = JSON.parse(value)
+ return parsed && typeof parsed === 'object' ? parsed as Record : null
+ }
+ catch {
+ return null
+ }
+}
+
+const getCookieUtmInfo = () => {
+ return parseJSONRecord(Cookies.get('utm_info'))
+}
+
+const mapExternalUtmSource = (value?: string) => {
+ if (!value)
+ return undefined
+
+ const normalized = value.toLowerCase()
+ return EXTERNAL_UTM_SOURCE_MAP[normalized as keyof typeof EXTERNAL_UTM_SOURCE_MAP]
+}
+
+export const extractExternalCreateAppAttribution = ({
+ searchParams,
+ utmInfo,
+}: {
+ searchParams?: SearchParamReader | null
+ utmInfo?: Record | null
+}) => {
+ const rawSource = getSearchParamValue(searchParams, 'utm_source') ?? getObjectStringValue(utmInfo?.utm_source)
+ const mappedSource = mapExternalUtmSource(rawSource)
+
+ if (!mappedSource)
+ return null
+
+ const utmCampaign = getSearchParamValue(searchParams, 'slug')
+ ?? getSearchParamValue(searchParams, 'utm_campaign')
+ ?? getObjectStringValue(utmInfo?.slug)
+ ?? getObjectStringValue(utmInfo?.utm_campaign)
+
+ return {
+ utmSource: mappedSource,
+ ...(utmCampaign ? { utmCampaign } : {}),
+ } satisfies ExternalCreateAppAttribution
+}
+
+const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribution | null => {
+ if (typeof window === 'undefined')
+ return null
+
+ return parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)) as ExternalCreateAppAttribution | null
+}
+
+const writeRememberedExternalCreateAppAttribution = (attribution: ExternalCreateAppAttribution) => {
+ if (typeof window === 'undefined')
+ return
+
+ window.sessionStorage.setItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY, JSON.stringify(attribution))
+}
+
+const clearRememberedExternalCreateAppAttribution = () => {
+ if (typeof window === 'undefined')
+ return
+
+ window.sessionStorage.removeItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)
+}
+
+export const rememberCreateAppExternalAttribution = ({
+ searchParams,
+ utmInfo,
+}: {
+ searchParams?: SearchParamReader | null
+ utmInfo?: Record | null
+} = {}) => {
+ const attribution = extractExternalCreateAppAttribution({
+ searchParams,
+ utmInfo: utmInfo ?? getCookieUtmInfo(),
+ })
+
+ if (attribution)
+ writeRememberedExternalCreateAppAttribution(attribution)
+
+ return attribution
+}
+
+const resolveCurrentExternalCreateAppAttribution = () => {
+ if (typeof window === 'undefined')
+ return null
+
+ return rememberCreateAppExternalAttribution({
+ searchParams: new URLSearchParams(window.location.search),
+ }) ?? readRememberedExternalCreateAppAttribution()
+}
+
+export const buildCreateAppEventPayload = (
+ params: TrackCreateAppParams,
+ externalAttribution?: ExternalCreateAppAttribution | null,
+) => {
+ if (externalAttribution) {
+ return {
+ source: 'external',
+ utm_source: externalAttribution.utmSource,
+ ...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}),
+ } satisfies Record
+ }
+
+ if ('templateId' in params) {
+ return {
+ source: params.source,
+ template_id: params.templateId,
+ } satisfies Record
+ }
+
+ return {
+ source: params.source,
+ } satisfies Record
+}
+
+export const trackCreateApp = (params: TrackCreateAppParams) => {
+ const externalAttribution = resolveCurrentExternalCreateAppAttribution()
+ const payload = buildCreateAppEventPayload(params, externalAttribution)
+
+ if (externalAttribution)
+ clearRememberedExternalCreateAppAttribution()
+
+ trackEvent('create_app', payload)
+}