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