Compare commits

...

2 Commits

Author SHA1 Message Date
7f392b6950 chore(release): bump version to 1.14.2 (#36313)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 13:27:26 +08:00
b0a3399774 feat: enhance app creation tracking with source and template ID (#36369)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-19 05:02:17 +00:00
19 changed files with 244 additions and 86 deletions

View File

@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.14.1"
version = "1.14.2"
requires-python = "~=3.12.0"
dependencies = [

2
api/uv.lock generated
View File

@ -1323,7 +1323,7 @@ docs = [
[[package]]
name = "dify-api"
version = "1.14.1"
version = "1.14.2"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },

View File

@ -220,7 +220,7 @@ services:
# API service
api:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -264,7 +264,7 @@ services:
# WebSocket service for workflow collaboration.
api_websocket:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
profiles:
- collaboration
environment:
@ -290,7 +290,7 @@ services:
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -333,7 +333,7 @@ services:
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: beat
depends_on:
@ -366,7 +366,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.14.1
image: langgenius/dify-web:1.14.2
restart: always
env_file:
- path: ./envs/core-services/web.env

View File

@ -226,7 +226,7 @@ services:
# API service
api:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -270,7 +270,7 @@ services:
# WebSocket service for workflow collaboration.
api_websocket:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
profiles:
- collaboration
environment:
@ -296,7 +296,7 @@ services:
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -339,7 +339,7 @@ services:
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: beat
depends_on:
@ -372,7 +372,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.14.1
image: langgenius/dify-web:1.14.2
restart: always
env_file:
- path: ./envs/core-services/web.env

View File

@ -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)

View File

@ -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()

View File

@ -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' }))

View File

@ -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()

View File

@ -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()

View File

@ -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 () => {

View File

@ -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)

View File

@ -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(<Apps />)
@ -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(<Apps />)
@ -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(<Apps />)
@ -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(<Apps />)
@ -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',
})
})
})
})

View File

@ -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<TryAppSelection | undefined>(undefined)
const currentCreateAppModeRef = useRef<TryAppSelection['app']['app']['mode'] | null>(null)
const currentCreateAppTrackingRef = useRef<Pick<TrackCreateAppParams, 'source' | 'templateId'> | 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: () => {

View File

@ -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',
})
})
})

View File

@ -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<TryAppSelection | undefined>(undefined)
const currentCreateAppModeRef = useRef<App['app']['mode'] | null>(null)
const currentCreateAppTrackingRef = useRef<Pick<TrackCreateAppParams, 'source' | 'templateId'> | 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)
}}

View File

@ -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')

View File

@ -1,7 +1,7 @@
{
"name": "dify-web",
"type": "module",
"version": "1.14.1",
"version": "1.14.2",
"private": true,
"imports": {
"#i18n": {

View File

@ -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()
})
})
})

View File

@ -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<string, string>
}
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<string, string>
}
@ -233,6 +262,9 @@ export const trackCreateApp = (params: TrackCreateAppParams) => {
const externalAttribution = resolveCurrentExternalCreateAppAttribution()
const payload = buildCreateAppEventPayload(params, externalAttribution)
if (!payload)
return
if (externalAttribution)
clearRememberedExternalCreateAppAttribution()