diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx
index 96798ac6a9..dbbbbee456 100644
--- a/web/__tests__/explore/explore-app-list-flow.test.tsx
+++ b/web/__tests__/explore/explore-app-list-flow.test.tsx
@@ -236,8 +236,8 @@ describe('Explore App List Flow', () => {
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
options.onPending?.()
})
- mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
- options.onSuccess?.()
+ mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
+ options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderAppList(true, onSuccess)
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 cd6c6b57eb..6f76273c77 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
@@ -247,7 +247,9 @@ describe('Apps', () => {
})
expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'studio_template_list',
appMode: AppModeEnum.CHAT,
+ templateId: 'Alpha',
})
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
expect(onSuccess).toHaveBeenCalled()
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 b0f0b8ca59..20bb18460b 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
@@ -127,7 +127,7 @@ const Apps = ({
icon_background,
description,
})
- trackCreateApp({ appMode: mode })
+ trackCreateApp({ source: 'studio_template_list', appMode: mode, 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 24fb21747f..bf1e698fc6 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
@@ -170,7 +170,7 @@ describe('CreateAppModal', () => {
mode: AppModeEnum.ADVANCED_CHAT,
}))
- expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.ADVANCED_CHAT })
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_blank', appMode: AppModeEnum.ADVANCED_CHAT })
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 c27231bc87..e81ad0f862 100644
--- a/web/app/components/app/create-app-modal/index.tsx
+++ b/web/app/components/app/create-app-modal/index.tsx
@@ -79,7 +79,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
mode: appMode,
})
- trackCreateApp({ appMode: app.mode })
+ trackCreateApp({ source: 'studio_blank', appMode: app.mode })
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 dac5b22ee6..c6be430fac 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
@@ -197,7 +197,7 @@ describe('CreateFromDSLModal', () => {
mode: DSLImportMode.YAML_URL,
yaml_url: 'https://example.com/app.yml',
})
- expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.CHAT })
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload', appMode: AppModeEnum.CHAT })
expect(handleSuccess).toHaveBeenCalledTimes(1)
expect(handleClose).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1')
@@ -304,7 +304,7 @@ describe('CreateFromDSLModal', () => {
expect(mockImportDSLConfirm).toHaveBeenCalledWith({
import_id: 'import-3',
})
- expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.WORKFLOW })
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload', appMode: AppModeEnum.WORKFLOW })
})
it('should close the DSL mismatch modal when dialog requests close', 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 5b8a4e7469..85ed284506 100644
--- a/web/app/components/app/create-from-dsl-modal/index.tsx
+++ b/web/app/components/app/create-from-dsl-modal/index.tsx
@@ -110,7 +110,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) {
- trackCreateApp({ appMode: app_mode })
+ trackCreateApp({ source: 'studio_upload', appMode: app_mode })
if (onSuccess)
onSuccess()
@@ -171,7 +171,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
const { status, app_id, app_mode } = response
if (status === DSLImportStatus.COMPLETED) {
- trackCreateApp({ appMode: app_mode })
+ trackCreateApp({ source: 'studio_upload', appMode: app_mode })
if (onSuccess)
onSuccess()
if (onClose)
diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx
index 7085852e8f..32ebd35eba 100644
--- a/web/app/components/apps/__tests__/index.spec.tsx
+++ b/web/app/components/apps/__tests__/index.spec.tsx
@@ -262,8 +262,8 @@ describe('Apps', () => {
})
it('should track template preview creation after a successful import', async () => {
- mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
- options.onSuccess?.()
+ mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
+ options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderWithClient()
@@ -275,7 +275,9 @@ describe('Apps', () => {
await waitFor(() => {
expect(mockFetchAppDetail).toHaveBeenCalledWith('template-1')
expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'studio_template_preview',
appMode: AppModeEnum.CHAT,
+ templateId: 'template-1',
})
})
})
@@ -284,8 +286,8 @@ describe('Apps', () => {
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
options.onPending?.()
})
- mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
- options.onSuccess?.()
+ mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
+ options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW })
})
renderWithClient()
@@ -299,7 +301,9 @@ describe('Apps', () => {
await waitFor(() => {
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
expect(mockTrackCreateApp).toHaveBeenCalledWith({
- appMode: AppModeEnum.CHAT,
+ source: 'studio_template_preview',
+ appMode: AppModeEnum.WORKFLOW,
+ templateId: 'template-1',
})
})
})
@@ -365,8 +369,8 @@ describe('Apps', () => {
})
it('should import DSL from marketplace template on confirm', async () => {
- mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
- options.onSuccess?.()
+ mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
+ options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
mockSearchParams = new URLSearchParams('template-id=tpl-42')
renderWithClient()
@@ -378,14 +382,22 @@ describe('Apps', () => {
{ mode: 'yaml-content', yaml_content: 'yaml-dsl-content' },
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'external',
+ appMode: AppModeEnum.CHAT,
+ templateId: 'tpl-42',
+ })
expect(mockReplace).toHaveBeenCalled()
})
})
- it('should show DSL confirm modal when marketplace import is pending', async () => {
+ it('should track marketplace template creation after confirming a pending import', async () => {
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
options.onPending?.()
})
+ mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
+ options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW })
+ })
mockSearchParams = new URLSearchParams('template-id=tpl-42')
renderWithClient()
@@ -395,6 +407,16 @@ describe('Apps', () => {
expect(screen.getByTestId('dsl-confirm-modal')).toBeInTheDocument()
expect(mockReplace).toHaveBeenCalled()
})
+
+ fireEvent.click(screen.getByTestId('confirm-dsl'))
+
+ await waitFor(() => {
+ expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'external',
+ appMode: AppModeEnum.WORKFLOW,
+ templateId: 'tpl-42',
+ })
+ })
})
})
diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx
index c42132e890..9c99411883 100644
--- a/web/app/components/apps/index.tsx
+++ b/web/app/components/apps/index.tsx
@@ -1,6 +1,7 @@
'use client'
import type { CreateAppModalProps } from '../explore/create-app-modal'
import type { TryAppSelection } from '@/types/try-app'
+import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEducationInit } from '@/app/education-apply/hooks'
@@ -31,6 +32,7 @@ const Apps = () => {
const [currentTryAppParams, setCurrentTryAppParams] = useState(undefined)
const currentCreateAppModeRef = useRef(null)
+ const currentCreateAppTrackingRef = useRef | null>(null)
const currApp = currentTryAppParams?.app
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
const hideTryAppPanel = useCallback(() => {
@@ -46,13 +48,24 @@ const Apps = () => {
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
const handleShowFromTryApp = useCallback(() => {
+ currentCreateAppTrackingRef.current = {
+ source: 'studio_template_preview',
+ templateId: currentTryAppParams?.appId || currentTryAppParams?.app.app_id,
+ }
setIsShowCreateModal(true)
- }, [])
- const trackCurrentCreateApp = useCallback(() => {
- if (!currentCreateAppModeRef.current)
+ }, [currentTryAppParams?.app.app_id, currentTryAppParams?.appId])
+ const trackCurrentCreateApp = useCallback((appMode?: TryAppSelection['app']['app']['mode'] | null) => {
+ const currentCreateAppTracking = currentCreateAppTrackingRef.current
+ const resolvedAppMode = appMode ?? currentCreateAppModeRef.current
+ if (!resolvedAppMode || !currentCreateAppTracking)
return
- trackCreateApp({ appMode: currentCreateAppModeRef.current })
+ trackCreateApp({
+ ...currentCreateAppTracking,
+ appMode: resolvedAppMode,
+ })
+ currentCreateAppTrackingRef.current = null
+ currentCreateAppModeRef.current = null
}, [])
const [controlRefreshList, setControlRefreshList] = useState(0)
@@ -81,19 +94,25 @@ const Apps = () => {
const onConfirmDSL = useCallback(async () => {
await handleImportDSLConfirm({
- onSuccess: () => {
- trackCurrentCreateApp()
+ onSuccess: (response) => {
+ trackCurrentCreateApp(response.app_mode)
onSuccess()
},
})
}, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
const handleMarketplaceTemplateConfirm = useCallback(async (dslContent: string) => {
+ currentCreateAppModeRef.current = null
+ currentCreateAppTrackingRef.current = {
+ source: 'external',
+ templateId: templateId || undefined,
+ }
await handleImportDSL({
mode: DSLImportMode.YAML_CONTENT,
yaml_content: dslContent,
}, {
- onSuccess: () => {
+ onSuccess: (response) => {
+ trackCurrentCreateApp(response.app_mode)
handleCloseTemplateModal()
onSuccess()
},
@@ -102,7 +121,7 @@ const Apps = () => {
setShowDSLConfirmModal(true)
},
})
- }, [handleImportDSL, handleCloseTemplateModal, onSuccess])
+ }, [handleImportDSL, handleCloseTemplateModal, onSuccess, templateId, trackCurrentCreateApp])
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
@@ -127,8 +146,8 @@ const Apps = () => {
description,
}
await handleImportDSL(payload, {
- onSuccess: () => {
- trackCurrentCreateApp()
+ onSuccess: (response) => {
+ trackCurrentCreateApp(response.app_mode)
setIsShowCreateModal(false)
},
onPending: () => {
diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx
index 7c9bdb7b23..0986df44e0 100644
--- a/web/app/components/explore/app-list/__tests__/index.spec.tsx
+++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx
@@ -239,8 +239,8 @@ describe('AppList', () => {
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
options.onPending?.()
})
- mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
- options.onSuccess?.()
+ mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
+ options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderAppList(true, onSuccess)
@@ -257,7 +257,9 @@ describe('AppList', () => {
await waitFor(() => {
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'explore_template_list',
appMode: AppModeEnum.CHAT,
+ templateId: 'app-1',
})
expect(onSuccess).toHaveBeenCalledTimes(1)
})
@@ -351,8 +353,8 @@ describe('AppList', () => {
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
- mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
- options.onSuccess?.()
+ mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
+ options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderAppList(true)
@@ -417,8 +419,8 @@ describe('AppList', () => {
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
- mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
- options.onSuccess?.()
+ mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
+ options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderAppList(true)
@@ -429,7 +431,9 @@ describe('AppList', () => {
await waitFor(() => {
expect(mockTrackCreateApp).toHaveBeenCalledWith({
+ source: 'explore_template_preview',
appMode: AppModeEnum.CHAT,
+ templateId: 'app-1',
})
})
})
diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx
index 86a0bbc6c0..41485e0e89 100644
--- a/web/app/components/explore/app-list/index.tsx
+++ b/web/app/components/explore/app-list/index.tsx
@@ -3,6 +3,7 @@
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
+import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
@@ -107,6 +108,7 @@ const Apps = ({
const [currentTryApp, setCurrentTryApp] = useState(undefined)
const currentCreateAppModeRef = useRef(null)
+ const currentCreateAppTrackingRef = useRef | null>(null)
const isShowTryAppPanel = !!currentTryApp
const hideTryAppPanel = useCallback(() => {
setCurrentTryApp(undefined)
@@ -116,13 +118,24 @@ const Apps = ({
}, [])
const handleShowFromTryApp = useCallback(() => {
setCurrApp(currentTryApp?.app || null)
+ currentCreateAppTrackingRef.current = {
+ source: 'explore_template_preview',
+ templateId: currentTryApp?.appId || currentTryApp?.app.app_id,
+ }
setIsShowCreateModal(true)
- }, [currentTryApp?.app])
- const trackCurrentCreateApp = useCallback(() => {
- if (!currentCreateAppModeRef.current)
+ }, [currentTryApp?.app, currentTryApp?.appId])
+ const trackCurrentCreateApp = useCallback((appMode?: App['app']['mode'] | null) => {
+ const currentCreateAppTracking = currentCreateAppTrackingRef.current
+ const resolvedAppMode = appMode ?? currentCreateAppModeRef.current
+ if (!resolvedAppMode || !currentCreateAppTracking)
return
- trackCreateApp({ appMode: currentCreateAppModeRef.current })
+ trackCreateApp({
+ ...currentCreateAppTracking,
+ appMode: resolvedAppMode,
+ })
+ currentCreateAppTrackingRef.current = null
+ currentCreateAppModeRef.current = null
}, [])
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
@@ -148,8 +161,8 @@ const Apps = ({
description,
}
await handleImportDSL(payload, {
- onSuccess: () => {
- trackCurrentCreateApp()
+ onSuccess: (response) => {
+ trackCurrentCreateApp(response.app_mode)
setIsShowCreateModal(false)
},
onPending: () => {
@@ -160,8 +173,8 @@ const Apps = ({
const onConfirmDSL = useCallback(async () => {
await handleImportDSLConfirm({
- onSuccess: () => {
- trackCurrentCreateApp()
+ onSuccess: (response) => {
+ trackCurrentCreateApp(response.app_mode)
onSuccess?.()
},
})
@@ -242,6 +255,10 @@ const Apps = ({
app={app}
canCreate={hasEditPermission}
onCreate={() => {
+ currentCreateAppTrackingRef.current = {
+ source: 'explore_template_list',
+ templateId: app.app_id,
+ }
setCurrApp(app)
setIsShowCreateModal(true)
}}
diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts
index 1ece2a27f0..83b11157cf 100644
--- a/web/hooks/use-import-dsl.ts
+++ b/web/hooks/use-import-dsl.ts
@@ -32,7 +32,7 @@ type DSLPayload = {
description?: string
}
type ResponseCallback = {
- onSuccess?: () => void
+ onSuccess?: (payload: DSLImportResponse) => void
onPending?: (payload: DSLImportResponse) => void
onFailed?: () => void
}
@@ -85,7 +85,7 @@ export const useImportDSL = () => {
toast.success(message)
else
toast.warning(message, { description })
- onSuccess?.()
+ onSuccess?.(response)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
await handleCheckPluginDependencies(app_id)
getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push)
@@ -134,7 +134,7 @@ export const useImportDSL = () => {
return
if (status === DSLImportStatus.COMPLETED) {
- onSuccess?.()
+ onSuccess?.(response)
toast.success(t('newApp.appCreated', { ns: 'app' }))
await handleCheckPluginDependencies(app_id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
diff --git a/web/utils/__tests__/create-app-tracking.spec.ts b/web/utils/__tests__/create-app-tracking.spec.ts
index b732d8d3b8..67e641811e 100644
--- a/web/utils/__tests__/create-app-tracking.spec.ts
+++ b/web/utils/__tests__/create-app-tracking.spec.ts
@@ -55,11 +55,12 @@ describe('create-app-tracking', () => {
})
describe('buildCreateAppEventPayload', () => {
- it('should build original payloads with normalized app mode and timestamp', () => {
+ it('should build payloads with source, normalized app mode, and timestamp', () => {
expect(buildCreateAppEventPayload({
+ source: 'studio_blank',
appMode: AppModeEnum.ADVANCED_CHAT,
}, null, new Date(2026, 3, 13, 14, 5, 9))).toEqual({
- source: 'original',
+ source: 'studio_blank',
app_mode: 'chatflow',
time: '04-13-14:05:09',
})
@@ -67,9 +68,10 @@ describe('create-app-tracking', () => {
it('should map agent mode into the canonical app mode bucket', () => {
expect(buildCreateAppEventPayload({
+ source: 'studio_blank',
appMode: AppModeEnum.AGENT_CHAT,
}, null, new Date(2026, 3, 13, 9, 8, 7))).toEqual({
- source: 'original',
+ source: 'studio_blank',
app_mode: 'agent',
time: '04-13-09:08:07',
})
@@ -77,17 +79,19 @@ describe('create-app-tracking', () => {
it('should fold legacy non-agent modes into chatflow', () => {
expect(buildCreateAppEventPayload({
+ source: 'studio_blank',
appMode: AppModeEnum.CHAT,
}, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({
- source: 'original',
+ source: 'studio_blank',
app_mode: 'chatflow',
time: '04-13-08:00:01',
})
expect(buildCreateAppEventPayload({
+ source: 'studio_blank',
appMode: AppModeEnum.COMPLETION,
}, null, new Date(2026, 3, 13, 8, 0, 2))).toEqual({
- source: 'original',
+ source: 'studio_blank',
app_mode: 'chatflow',
time: '04-13-08:00:02',
})
@@ -95,29 +99,56 @@ describe('create-app-tracking', () => {
it('should map workflow mode into the workflow bucket', () => {
expect(buildCreateAppEventPayload({
+ source: 'studio_blank',
appMode: AppModeEnum.WORKFLOW,
}, null, new Date(2026, 3, 13, 7, 6, 5))).toEqual({
- source: 'original',
+ source: 'studio_blank',
app_mode: 'workflow',
time: '04-13-07:06:05',
})
})
+ it('should include template_id for template sources', () => {
+ expect(buildCreateAppEventPayload({
+ source: 'studio_template_list',
+ appMode: AppModeEnum.CHAT,
+ templateId: 'template-1',
+ }, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({
+ source: 'studio_template_list',
+ app_mode: 'chatflow',
+ time: '04-13-08:00:01',
+ template_id: 'template-1',
+ })
+ })
+
it('should prefer external attribution when present', () => {
expect(buildCreateAppEventPayload(
{
+ source: 'studio_template_list',
appMode: AppModeEnum.WORKFLOW,
+ templateId: 'template-1',
},
{
utmSource: 'linkedin',
utmCampaign: 'agent-launch',
},
+ new Date(2026, 3, 13, 7, 6, 5),
)).toEqual({
source: 'external',
+ app_mode: 'workflow',
+ time: '04-13-07:06:05',
+ template_id: 'template-1',
utm_source: 'linkedin',
utm_campaign: 'agent-launch',
})
})
+
+ it('should not build external payloads without attribution', () => {
+ expect(buildCreateAppEventPayload({
+ source: 'external',
+ appMode: AppModeEnum.WORKFLOW,
+ }, null, new Date(2026, 3, 13, 7, 6, 5))).toBeNull()
+ })
})
describe('trackCreateApp', () => {
@@ -126,20 +157,24 @@ describe('create-app-tracking', () => {
searchParams: new URLSearchParams('utm_source=newsletter&slug=how-to-build-rag-agent'),
})
- trackCreateApp({ appMode: AppModeEnum.WORKFLOW })
+ trackCreateApp({ source: 'studio_template_list', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' })
expect(amplitude.trackEvent).toHaveBeenNthCalledWith(1, 'create_app', {
source: 'external',
+ app_mode: 'workflow',
+ time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
+ template_id: 'template-1',
utm_source: 'blog',
utm_campaign: 'how-to-build-rag-agent',
})
- trackCreateApp({ appMode: AppModeEnum.WORKFLOW })
+ trackCreateApp({ source: 'studio_template_list', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' })
expect(amplitude.trackEvent).toHaveBeenNthCalledWith(2, 'create_app', {
- source: 'original',
+ source: 'studio_template_list',
app_mode: 'workflow',
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
+ template_id: 'template-1',
})
})
@@ -152,16 +187,19 @@ describe('create-app-tracking', () => {
window.history.replaceState({}, '', '/explore')
- trackCreateApp({ appMode: AppModeEnum.CHAT })
+ trackCreateApp({ source: 'explore_template_preview', appMode: AppModeEnum.CHAT, templateId: 'template-2' })
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
source: 'external',
+ app_mode: 'chatflow',
+ time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
+ template_id: 'template-2',
utm_source: 'linkedin',
utm_campaign: 'agent-launch',
})
})
- it('should fall back to the original payload when window is unavailable', () => {
+ it('should fall back to the provided source when window is unavailable', () => {
const originalWindow = globalThis.window
try {
@@ -170,10 +208,10 @@ describe('create-app-tracking', () => {
value: undefined,
})
- trackCreateApp({ appMode: AppModeEnum.AGENT_CHAT })
+ trackCreateApp({ source: 'studio_blank', appMode: AppModeEnum.AGENT_CHAT })
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
- source: 'original',
+ source: 'studio_blank',
app_mode: 'agent',
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
})
@@ -185,5 +223,29 @@ describe('create-app-tracking', () => {
})
}
})
+
+ it('should read, normalize, and consume snake_case sessionStorage attribution', () => {
+ window.sessionStorage.setItem('create_app_external_attribution', JSON.stringify({
+ utm_source: 'twitter',
+ utm_campaign: 'launch-week',
+ }))
+
+ trackCreateApp({ source: 'studio_blank', appMode: AppModeEnum.CHAT })
+
+ expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
+ source: 'external',
+ app_mode: 'chatflow',
+ time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
+ utm_source: 'twitter/x',
+ utm_campaign: 'launch-week',
+ })
+ expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
+ })
+
+ it('should not track external source without remembered attribution', () => {
+ trackCreateApp({ source: 'external', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' })
+
+ expect(amplitude.trackEvent).not.toHaveBeenCalled()
+ })
})
})
diff --git a/web/utils/create-app-tracking.ts b/web/utils/create-app-tracking.ts
index f56e2c13fa..800171c894 100644
--- a/web/utils/create-app-tracking.ts
+++ b/web/utils/create-app-tracking.ts
@@ -6,12 +6,13 @@ const CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY = 'create_app_external_attribu
const CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS = ['utm_source', 'utm_campaign', 'slug'] as const
const EXTERNAL_UTM_SOURCE_MAP = {
- blog: 'blog',
- dify_blog: 'blog',
- linkedin: 'linkedin',
- newsletter: 'blog',
- twitter: 'twitter/x',
- x: 'twitter/x',
+ 'blog': 'blog',
+ 'dify_blog': 'blog',
+ 'linkedin': 'linkedin',
+ 'newsletter': 'blog',
+ 'twitter': 'twitter/x',
+ 'twitter/x': 'twitter/x',
+ 'x': 'twitter/x',
} as const
type SearchParamReader = {
@@ -20,8 +21,19 @@ type SearchParamReader = {
type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent'
-type TrackCreateAppParams = {
+type CreateAppSource
+ = | 'external'
+ | 'explore_template_list'
+ | 'explore_template_preview'
+ | 'studio_blank'
+ | 'studio_template_list'
+ | 'studio_template_preview'
+ | 'studio_upload'
+
+export type TrackCreateAppParams = {
+ source: CreateAppSource
appMode: AppModeEnum
+ templateId?: string
}
type ExternalCreateAppAttribution = {
@@ -173,7 +185,20 @@ export const extractExternalCreateAppAttribution = ({
}
const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribution | null => {
- return parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)) as ExternalCreateAppAttribution | null
+ const attribution = parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY))
+ const utmSource = mapExternalUtmSource(
+ getObjectStringValue(attribution?.utmSource) ?? getObjectStringValue(attribution?.utm_source),
+ )
+
+ if (!utmSource)
+ return null
+
+ const utmCampaign = getObjectStringValue(attribution?.utmCampaign) ?? getObjectStringValue(attribution?.utm_campaign)
+
+ return {
+ utmSource,
+ ...(utmCampaign ? { utmCampaign } : {}),
+ }
}
const writeRememberedExternalCreateAppAttribution = (attribution: ExternalCreateAppAttribution) => {
@@ -214,18 +239,22 @@ export const buildCreateAppEventPayload = (
externalAttribution?: ExternalCreateAppAttribution | null,
currentTime = new Date(),
) => {
- if (externalAttribution) {
- return {
- source: 'external',
- utm_source: externalAttribution.utmSource,
- ...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}),
- } satisfies Record
- }
+ const source = externalAttribution ? 'external' : params.source
+
+ if (source === 'external' && !externalAttribution)
+ return null
return {
- source: 'original',
+ source,
app_mode: mapOriginalCreateAppMode(params.appMode),
time: formatCreateAppTime(currentTime),
+ ...(params.templateId ? { template_id: params.templateId } : {}),
+ ...(externalAttribution
+ ? {
+ utm_source: externalAttribution.utmSource,
+ ...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}),
+ }
+ : {}),
} satisfies Record
}
@@ -233,6 +262,9 @@ export const trackCreateApp = (params: TrackCreateAppParams) => {
const externalAttribution = resolveCurrentExternalCreateAppAttribution()
const payload = buildCreateAppEventPayload(params, externalAttribution)
+ if (!payload)
+ return
+
if (externalAttribution)
clearRememberedExternalCreateAppAttribution()