From fb17339d8989cc12fa3adbdac5f1c67565b74914 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 15 Apr 2026 16:59:31 +0800 Subject: [PATCH] feat(web): unify create_app tracking and persist external attribution (#35241) Co-authored-by: CodingOnStar --- .../__tests__/app-initializer.spec.tsx | 197 ++++++++++++++ .../create-app-attribution-bootstrap.spec.tsx | 97 +++++++ .../app-list/__tests__/index.spec.tsx | 13 +- .../app/create-app-dialog/app-list/index.tsx | 11 +- .../create-app-modal/__tests__/index.spec.tsx | 13 +- .../components/app/create-app-modal/index.tsx | 8 +- .../__tests__/index.spec.tsx | 21 +- .../app/create-from-dsl-modal/index.tsx | 10 +- .../components/apps/__tests__/index.spec.tsx | 200 ++++++++++++++- web/app/components/apps/index.tsx | 25 +- .../create-app-attribution-bootstrap.tsx | 23 ++ .../explore/app-list/__tests__/index.spec.tsx | 40 ++- web/app/components/explore/app-list/index.tsx | 25 +- web/app/layout.tsx | 2 + web/app/signup/set-password/page.tsx | 2 + .../__tests__/create-app-tracking.spec.ts | 189 ++++++++++++++ web/utils/create-app-tracking.ts | 240 ++++++++++++++++++ 17 files changed, 1045 insertions(+), 71 deletions(-) create mode 100644 web/app/components/__tests__/app-initializer.spec.tsx create mode 100644 web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx create mode 100644 web/app/components/create-app-attribution-bootstrap.tsx create mode 100644 web/utils/__tests__/create-app-tracking.spec.ts create mode 100644 web/utils/create-app-tracking.ts diff --git a/web/app/components/__tests__/app-initializer.spec.tsx b/web/app/components/__tests__/app-initializer.spec.tsx new file mode 100644 index 0000000000..b4c2d08f2e --- /dev/null +++ b/web/app/components/__tests__/app-initializer.spec.tsx @@ -0,0 +1,197 @@ +import { screen, waitFor } from '@testing-library/react' +import Cookies from 'js-cookie' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, + EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, +} from '@/app/education-apply/constants' +import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect' +import { usePathname, useRouter, useSearchParams } from '@/next/navigation' +import { renderWithNuqs } from '@/test/nuqs-testing' +import { fetchSetupStatusWithCache } from '@/utils/setup-status' +import { AppInitializer } from '../app-initializer' + +const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({ + mockSendGAEvent: vi.fn(), + mockTrackEvent: vi.fn(), +})) + +vi.mock('@/next/navigation', () => ({ + usePathname: vi.fn(), + useRouter: vi.fn(), + useSearchParams: vi.fn(), +})) + +vi.mock('@/utils/setup-status', () => ({ + fetchSetupStatusWithCache: vi.fn(), +})) + +vi.mock('@/app/signin/utils/post-login-redirect', () => ({ + resolvePostLoginRedirect: vi.fn(), +})) + +vi.mock('@/utils/gtag', () => ({ + sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args), +})) + +vi.mock('../base/amplitude', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +const mockUsePathname = vi.mocked(usePathname) +const mockUseRouter = vi.mocked(useRouter) +const mockUseSearchParams = vi.mocked(useSearchParams) +const mockFetchSetupStatusWithCache = vi.mocked(fetchSetupStatusWithCache) +const mockResolvePostLoginRedirect = vi.mocked(resolvePostLoginRedirect) +const mockReplace = vi.fn() + +describe('AppInitializer', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + window.localStorage.clear() + window.sessionStorage.clear() + Cookies.remove('utm_info') + vi.spyOn(console, 'error').mockImplementation(() => {}) + mockUsePathname.mockReturnValue('/apps') + mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType) + mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType) + mockFetchSetupStatusWithCache.mockResolvedValue({ step: 'finished' }) + mockResolvePostLoginRedirect.mockReturnValue(null) + }) + + it('renders children after setup checks finish', async () => { + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()) + + expect(mockFetchSetupStatusWithCache).toHaveBeenCalledTimes(1) + expect(mockReplace).not.toHaveBeenCalledWith('/signin') + }) + + it('redirects to install when setup status loading fails', async () => { + mockFetchSetupStatusWithCache.mockRejectedValue(new Error('unauthorized')) + + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/install')) + expect(screen.queryByText('ready')).not.toBeInTheDocument() + }) + + it('does not persist create app attribution from the url anymore', async () => { + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()) + + expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull() + }) + + it('tracks oauth registration with utm info and clears the cookie', async () => { + Cookies.set('utm_info', JSON.stringify({ + utm_source: 'linkedin', + slug: 'agent-launch', + })) + + renderWithNuqs( + +
ready
+
, + { searchParams: 'oauth_new_user=true' }, + ) + + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()) + + expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', { + method: 'oauth', + utm_source: 'linkedin', + slug: 'agent-launch', + }) + expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', { + method: 'oauth', + utm_source: 'linkedin', + slug: 'agent-launch', + }) + expect(mockReplace).toHaveBeenCalledWith('/apps') + expect(Cookies.get('utm_info')).toBeUndefined() + }) + + it('falls back to the base registration event when the oauth utm cookie is invalid', async () => { + Cookies.set('utm_info', '{invalid-json') + + renderWithNuqs( + +
ready
+
, + { searchParams: 'oauth_new_user=true' }, + ) + + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()) + + expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', { + method: 'oauth', + }) + expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', { + method: 'oauth', + }) + expect(console.error).toHaveBeenCalled() + expect(Cookies.get('utm_info')).toBeUndefined() + }) + + it('stores the education verification flag in localStorage', async () => { + mockUseSearchParams.mockReturnValue( + new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType, + ) + + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()) + + expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes') + }) + + it('redirects to the resolved post-login url when one exists', async () => { + const mockLocationReplace = vi.fn() + vi.stubGlobal('location', { ...window.location, replace: mockLocationReplace }) + mockResolvePostLoginRedirect.mockReturnValue('/explore') + + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(mockLocationReplace).toHaveBeenCalledWith('/explore')) + expect(screen.queryByText('ready')).not.toBeInTheDocument() + }) + + it('redirects to signin when redirect resolution throws', async () => { + mockResolvePostLoginRedirect.mockImplementation(() => { + throw new Error('redirect resolution failed') + }) + + renderWithNuqs( + +
ready
+
, + ) + + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/signin')) + expect(screen.queryByText('ready')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx b/web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx new file mode 100644 index 0000000000..a6cdf2cbf6 --- /dev/null +++ b/web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx @@ -0,0 +1,97 @@ +import type { ReactElement, ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runCreateAppAttributionBootstrap } from '@/utils/create-app-tracking' + +let mockIsProd = false +let mockNonce: string | null = 'test-nonce' + +type BootstrapScriptProps = { + id?: string + strategy?: string + nonce?: string + children?: string +} + +vi.mock('@/config', () => ({ + get IS_PROD() { return mockIsProd }, +})) + +vi.mock('@/next/headers', () => ({ + headers: vi.fn(() => ({ + get: vi.fn((name: string) => { + if (name === 'x-nonce') + return mockNonce + return null + }), + })), +})) + +const loadComponent = async () => { + const mod = await import('../create-app-attribution-bootstrap') + const rawExport = mod.default as unknown + const renderer: (() => Promise) | undefined + = typeof rawExport === 'function' ? rawExport as () => Promise : (rawExport as { type?: () => Promise }).type + + if (!renderer) + throw new Error('CreateAppAttributionBootstrap component is not callable in tests') + + return renderer +} + +const runBootstrapScript = () => { + runCreateAppAttributionBootstrap() +} + +describe('CreateAppAttributionBootstrap', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + mockIsProd = false + mockNonce = 'test-nonce' + window.sessionStorage.clear() + window.history.replaceState({}, '', '/apps') + }) + + it('renders a beforeInteractive script element', async () => { + const renderComponent = await loadComponent() + const element = await renderComponent() as ReactElement + + expect(element).toBeTruthy() + expect(element.props.id).toBe('create-app-attribution-bootstrap') + expect(element.props.strategy).toBe('beforeInteractive') + expect(element.props.children).toContain('window.sessionStorage.setItem') + }) + + it('uses the nonce header in production', async () => { + mockIsProd = true + mockNonce = 'prod-nonce' + + const renderComponent = await loadComponent() + const element = await renderComponent() as ReactElement + + expect(element.props.nonce).toBe('prod-nonce') + }) + + it('stores external attribution and clears only attribution params from the url', () => { + window.history.replaceState({}, '', '/apps?action=keep&utm_source=dify_blog&slug=get-started-with-dif#preview') + + runBootstrapScript() + + expect(window.sessionStorage.getItem('create_app_external_attribution')).toBe(JSON.stringify({ + utmSource: 'blog', + utmCampaign: 'get-started-with-dif', + })) + expect(window.location.pathname).toBe('/apps') + expect(window.location.search).toBe('?action=keep') + expect(window.location.hash).toBe('#preview') + }) + + it('does nothing for invalid external sources', () => { + window.history.replaceState({}, '', '/apps?action=keep&utm_source=internal&slug=ignored') + + runBootstrapScript() + + expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull() + expect(window.location.search).toBe('?action=keep&utm_source=internal&slug=ignored') + }) +}) 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..a319bb58f7 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,9 @@ describe('Apps', () => { })) }) - expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_template', expect.objectContaining({ - template_id: 'Alpha', - template_name: 'Alpha', - })) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + appMode: AppModeEnum.CHAT, + }) 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..daf49115c8 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,7 @@ 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, - }) + trackCreateApp({ appMode: mode }) 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..3e06b89f0e 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({ 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 c0c70660bc..61681892d2 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 Divider from '@/app/components/base/divider' import FullScreenModal from '@/app/components/base/fullscreen-modal' @@ -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({ 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 c1ffbc22e8..e106cc7eb3 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 @@ -2,12 +2,13 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { DSLImportMode, DSLImportStatus } from '@/models/app' +import { AppModeEnum } from '@/types/app' 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 +44,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', () => ({ @@ -172,7 +173,7 @@ describe('CreateFromDSLModal', () => { id: 'import-1', status: DSLImportStatus.COMPLETED, app_id: 'app-1', - app_mode: 'chat', + app_mode: AppModeEnum.CHAT, }) render( @@ -196,10 +197,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({ appMode: AppModeEnum.CHAT }) expect(handleSuccess).toHaveBeenCalledTimes(1) expect(handleClose).toHaveBeenCalledTimes(1) expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1') @@ -212,7 +210,7 @@ describe('CreateFromDSLModal', () => { id: 'import-2', status: DSLImportStatus.COMPLETED_WITH_WARNINGS, app_id: 'app-2', - app_mode: 'chat', + app_mode: AppModeEnum.CHAT, }) render( @@ -275,7 +273,7 @@ describe('CreateFromDSLModal', () => { mockImportDSLConfirm.mockResolvedValue({ status: DSLImportStatus.COMPLETED, app_id: 'app-3', - app_mode: 'workflow', + app_mode: AppModeEnum.WORKFLOW, }) render( @@ -305,6 +303,7 @@ describe('CreateFromDSLModal', () => { expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-3', }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.WORKFLOW }) }) it('should ignore empty import responses and prevent duplicate submissions while a request is in flight', async () => { @@ -332,7 +331,7 @@ describe('CreateFromDSLModal', () => { id: 'import-in-flight', status: DSLImportStatus.COMPLETED, app_id: 'app-1', - app_mode: 'chat', + app_mode: AppModeEnum.CHAT, }) }) 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 1fd985c7f8..d588610f71 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 Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import { Button } from '@/app/components/base/ui/button' @@ -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({ appMode: app_mode }) 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({ 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 da4fbc2d44..2e0d1bcc84 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,83 @@ 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, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( +
+ + +
+ ), +})) + +vi.mock('@/service/explore', () => ({ + fetchAppDetail: vi.fn(), +})) + +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), })) describe('Apps', () => { @@ -59,6 +161,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 +226,82 @@ 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({ + appMode: AppModeEnum.CHAT, + }) + }) + }) + + it('should track template preview creation after confirming a pending import', async () => { + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }) + mockHandleImportDSLConfirm.mockImplementation(async (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')) + + fireEvent.click(await screen.findByTestId('confirm-dsl')) + + await waitFor(() => { + expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + appMode: AppModeEnum.CHAT, + }) + }) + }) + + it('should close the dsl confirm modal when the pending import is canceled', async () => { + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }) + + renderWithClient() + + fireEvent.click(screen.getByTestId('open-preview')) + fireEvent.click(await screen.findByTestId('try-app-create')) + fireEvent.click(await screen.findByTestId('confirm-create')) + + fireEvent.click(await screen.findByTestId('cancel-dsl')) + + await waitFor(() => { + expect(screen.queryByTestId('dsl-confirm-modal')).not.toBeInTheDocument() + }) + expect(mockTrackCreateApp).not.toHaveBeenCalled() + }) + + it('should hide the create modal without tracking when the modal closes', async () => { + renderWithClient() + + fireEvent.click(screen.getByTestId('open-preview')) + fireEvent.click(await screen.findByTestId('try-app-create')) + + fireEvent.click(await screen.findByTestId('hide-create')) + + await waitFor(() => { + expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() + }) + expect(mockTrackCreateApp).not.toHaveBeenCalled() + }) }) describe('Styling', () => { diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index b6ca60bd7b..9bf07e81e6 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { CreateAppModalProps } from '../explore/create-app-modal' import type { TryAppSelection } from '@/types/try-app' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useEducationInit } from '@/app/education-apply/hooks' import AppListContext from '@/context/app-list-context' @@ -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 }) @@ -23,6 +24,7 @@ const Apps = () => { useEducationInit() const [currentTryAppParams, setCurrentTryAppParams] = useState(undefined) + const currentCreateAppModeRef = useRef(null) const currApp = currentTryAppParams?.app const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false) const hideTryAppPanel = useCallback(() => { @@ -40,6 +42,12 @@ const Apps = () => { const handleShowFromTryApp = useCallback(() => { setIsShowCreateModal(true) }, []) + const trackCurrentCreateApp = useCallback(() => { + if (!currentCreateAppModeRef.current) + return + + trackCreateApp({ appMode: currentCreateAppModeRef.current }) + }, []) 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, @@ -72,9 +83,10 @@ const Apps = () => { }) => { hideTryAppPanel() - const { export_data } = await fetchAppDetail( + const { export_data, mode } = await fetchAppDetail( currApp?.app.id as string, ) + currentCreateAppModeRef.current = mode const payload = { mode: DSLImportMode.YAML_CONTENT, yaml_content: export_data, @@ -86,13 +98,14 @@ const Apps = () => { } await handleImportDSL(payload, { onSuccess: () => { + trackCurrentCreateApp() setIsShowCreateModal(false) }, onPending: () => { setShowDSLConfirmModal(true) }, }) - } + }, [currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp]) return ( { + const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? '' : '' + /* v8 ignore next -- `nonce` is always a string (`''` or header value), so nullish fallback is unreachable in runtime. @preserve */ + const scriptNonce = nonce ?? undefined + + return ( + + ) +} + +export default memo(CreateAppAttributionBootstrap) 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 5d7dffd40a..67312f5ec3 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -15,6 +15,7 @@ let mockIsLoading = false let mockIsError = false const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() +const mockTrackCreateApp = vi.fn() vi.mock('@/service/use-explore', () => ({ 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) => { @@ -214,7 +218,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => { options.onPending?.() }) @@ -235,6 +239,9 @@ describe('AppList', () => { fireEvent.click(screen.getByTestId('dsl-confirm')) await waitFor(() => { expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + appMode: AppModeEnum.CHAT, + }) expect(onSuccess).toHaveBeenCalledTimes(1) }) }) @@ -307,7 +314,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) renderAppList(true) fireEvent.click(screen.getByText('explore.appCard.addToWorkspace')) @@ -317,6 +324,7 @@ describe('AppList', () => { await waitFor(() => { expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() }) + expect(mockTrackCreateApp).not.toHaveBeenCalled() }) it('should close create modal on successful DSL import', async () => { @@ -325,7 +333,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { options.onSuccess?.() }) @@ -345,7 +353,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { options.onPending?.() }) @@ -385,6 +393,30 @@ 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', mode: AppModeEnum.CHAT }) + 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({ + appMode: AppModeEnum.CHAT, + }) + }) + }) + 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 1261c0949c..f52fa44c4f 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -6,7 +6,7 @@ import type { TryAppSelection } from '@/types/try-app' import { useDebounceFn } from 'ahooks' import { useQueryState } from 'nuqs' import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal' import Input from '@/app/components/base/input' @@ -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' @@ -101,6 +102,7 @@ const Apps = ({ const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) const [currentTryApp, setCurrentTryApp] = useState(undefined) + const currentCreateAppModeRef = useRef(null) const isShowTryAppPanel = !!currentTryApp const hideTryAppPanel = useCallback(() => { setCurrentTryApp(undefined) @@ -112,8 +114,14 @@ const Apps = ({ setCurrApp(currentTryApp?.app || null) setIsShowCreateModal(true) }, [currentTryApp?.app]) + const trackCurrentCreateApp = useCallback(() => { + if (!currentCreateAppModeRef.current) + return - const onCreate: CreateAppModalProps['onConfirm'] = async ({ + trackCreateApp({ appMode: currentCreateAppModeRef.current }) + }, []) + + const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, icon, @@ -122,9 +130,10 @@ const Apps = ({ }) => { hideTryAppPanel() - const { export_data } = await fetchAppDetail( + const { export_data, mode } = await fetchAppDetail( currApp?.app.id as string, ) + currentCreateAppModeRef.current = mode const payload = { mode: DSLImportMode.YAML_CONTENT, yaml_content: export_data, @@ -136,19 +145,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 ( diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 03107f5d15..63e506bd45 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -9,6 +9,7 @@ import { getLocaleOnServer } from '@/i18n-config/server' import { ToastHost } from './components/base/ui/toast' import { TooltipProvider } from './components/base/ui/tooltip' import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder' +import CreateAppAttributionBootstrap from './components/create-app-attribution-bootstrap' import { AgentationLoader } from './components/devtools/agentation-loader' import { ReactScanLoader } from './components/devtools/react-scan/loader' import { I18nServerProvider } from './components/provider/i18n-server' @@ -47,6 +48,7 @@ const LocaleLayout = async ({ + {/* */} diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx index 72a25d6ac2..4a662e0623 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..b732d8d3b8 --- /dev/null +++ b/web/utils/__tests__/create-app-tracking.spec.ts @@ -0,0 +1,189 @@ +import Cookies from 'js-cookie' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as amplitude from '@/app/components/base/amplitude' +import { AppModeEnum } from '@/types/app' +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('rememberCreateAppExternalAttribution', () => { + it('should ignore malformed utm cookies', () => { + vi.spyOn(Cookies, 'get').mockImplementation(((key?: string) => { + return key ? 'not-json' : {} + }) as typeof Cookies.get) + + expect(rememberCreateAppExternalAttribution()).toBeNull() + expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull() + }) + }) + + describe('buildCreateAppEventPayload', () => { + it('should build original payloads with normalized app mode and timestamp', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.ADVANCED_CHAT, + }, null, new Date(2026, 3, 13, 14, 5, 9))).toEqual({ + source: 'original', + app_mode: 'chatflow', + time: '04-13-14:05:09', + }) + }) + + it('should map agent mode into the canonical app mode bucket', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.AGENT_CHAT, + }, null, new Date(2026, 3, 13, 9, 8, 7))).toEqual({ + source: 'original', + app_mode: 'agent', + time: '04-13-09:08:07', + }) + }) + + it('should fold legacy non-agent modes into chatflow', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.CHAT, + }, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({ + source: 'original', + app_mode: 'chatflow', + time: '04-13-08:00:01', + }) + + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.COMPLETION, + }, null, new Date(2026, 3, 13, 8, 0, 2))).toEqual({ + source: 'original', + app_mode: 'chatflow', + time: '04-13-08:00:02', + }) + }) + + it('should map workflow mode into the workflow bucket', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.WORKFLOW, + }, null, new Date(2026, 3, 13, 7, 6, 5))).toEqual({ + source: 'original', + app_mode: 'workflow', + time: '04-13-07:06:05', + }) + }) + + it('should prefer external attribution when present', () => { + expect(buildCreateAppEventPayload( + { + appMode: AppModeEnum.WORKFLOW, + }, + { + 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({ appMode: AppModeEnum.WORKFLOW }) + + expect(amplitude.trackEvent).toHaveBeenNthCalledWith(1, 'create_app', { + source: 'external', + utm_source: 'blog', + utm_campaign: 'how-to-build-rag-agent', + }) + + trackCreateApp({ appMode: AppModeEnum.WORKFLOW }) + + expect(amplitude.trackEvent).toHaveBeenNthCalledWith(2, 'create_app', { + source: 'original', + app_mode: 'workflow', + time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/), + }) + }) + + it('should keep using remembered external attribution after navigating away from the original url', () => { + window.history.replaceState({}, '', '/apps?utm_source=linkedin&slug=agent-launch') + + rememberCreateAppExternalAttribution({ + searchParams: new URLSearchParams(window.location.search), + }) + + window.history.replaceState({}, '', '/explore') + + trackCreateApp({ appMode: AppModeEnum.CHAT }) + + expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', { + source: 'external', + utm_source: 'linkedin', + utm_campaign: 'agent-launch', + }) + }) + + it('should fall back to the original payload when window is unavailable', () => { + const originalWindow = globalThis.window + + try { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: undefined, + }) + + trackCreateApp({ appMode: AppModeEnum.AGENT_CHAT }) + + expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', { + source: 'original', + app_mode: 'agent', + time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/), + }) + } + finally { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }) + } + }) + }) +}) diff --git a/web/utils/create-app-tracking.ts b/web/utils/create-app-tracking.ts new file mode 100644 index 0000000000..f56e2c13fa --- /dev/null +++ b/web/utils/create-app-tracking.ts @@ -0,0 +1,240 @@ +import Cookies from 'js-cookie' +import { trackEvent } from '@/app/components/base/amplitude' +import { AppModeEnum } from '@/types/app' + +const CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY = 'create_app_external_attribution' +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', +} as const + +type SearchParamReader = { + get: (name: string) => string | null +} + +type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent' + +type TrackCreateAppParams = { + appMode: AppModeEnum +} + +type ExternalCreateAppAttribution = { + utmSource: typeof EXTERNAL_UTM_SOURCE_MAP[keyof typeof EXTERNAL_UTM_SOURCE_MAP] + utmCampaign?: string +} + +const serializeBootstrapValue = (value: unknown) => { + return JSON.stringify(value).replace(/ { + 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] +} + +const padTimeValue = (value: number) => String(value).padStart(2, '0') + +const formatCreateAppTime = (date: Date) => { + return `${padTimeValue(date.getMonth() + 1)}-${padTimeValue(date.getDate())}-${padTimeValue(date.getHours())}:${padTimeValue(date.getMinutes())}:${padTimeValue(date.getSeconds())}` +} + +const mapOriginalCreateAppMode = (appMode: AppModeEnum): OriginalCreateAppMode => { + if (appMode === AppModeEnum.WORKFLOW) + return 'workflow' + + if (appMode === AppModeEnum.AGENT_CHAT) + return 'agent' + + return 'chatflow' +} + +export const runCreateAppAttributionBootstrap = ( + sourceMap = EXTERNAL_UTM_SOURCE_MAP, + storageKey = CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY, + queryKeys = CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS, +) => { + try { + if (typeof window === 'undefined' || !window.sessionStorage) + return + + const searchParams = new URLSearchParams(window.location.search) + const rawSource = searchParams.get('utm_source') + + if (!rawSource) + return + + const normalizedSource = rawSource.trim().toLowerCase() + const mappedSource = sourceMap[normalizedSource as keyof typeof sourceMap] + + if (!mappedSource) + return + + const normalizedSlug = searchParams.get('slug')?.trim() + const normalizedCampaign = searchParams.get('utm_campaign')?.trim() + const utmCampaign = normalizedSlug || normalizedCampaign + const attribution = utmCampaign + ? { utmSource: mappedSource, utmCampaign } + : { utmSource: mappedSource } + + window.sessionStorage.setItem(storageKey, JSON.stringify(attribution)) + + const nextSearchParams = new URLSearchParams(window.location.search) + let hasChanges = false + + queryKeys.forEach((key) => { + if (!nextSearchParams.has(key)) + return + + nextSearchParams.delete(key) + hasChanges = true + }) + + if (!hasChanges) + return + + const nextSearch = nextSearchParams.toString() + const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}` + + try { + window.history.replaceState(window.history.state, '', nextUrl) + } + catch {} + } + catch {} +} + +export const buildCreateAppAttributionBootstrapScript = () => { + return `(${runCreateAppAttributionBootstrap.toString()})(${serializeBootstrapValue(EXTERNAL_UTM_SOURCE_MAP)}, ${serializeBootstrapValue(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)}, ${serializeBootstrapValue(CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS)});` +} + +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 => { + return parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)) as ExternalCreateAppAttribution | null +} + +const writeRememberedExternalCreateAppAttribution = (attribution: ExternalCreateAppAttribution) => { + window.sessionStorage.setItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY, JSON.stringify(attribution)) +} + +const clearRememberedExternalCreateAppAttribution = () => { + 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 && typeof window !== 'undefined') + writeRememberedExternalCreateAppAttribution(attribution) + + return attribution +} + +const resolveCurrentExternalCreateAppAttribution = () => { + if (typeof window === 'undefined') + return null + + return readRememberedExternalCreateAppAttribution() +} + +export const buildCreateAppEventPayload = ( + params: TrackCreateAppParams, + externalAttribution?: ExternalCreateAppAttribution | null, + currentTime = new Date(), +) => { + if (externalAttribution) { + return { + source: 'external', + utm_source: externalAttribution.utmSource, + ...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}), + } satisfies Record + } + + return { + source: 'original', + app_mode: mapOriginalCreateAppMode(params.appMode), + time: formatCreateAppTime(currentTime), + } satisfies Record +} + +export const trackCreateApp = (params: TrackCreateAppParams) => { + const externalAttribution = resolveCurrentExternalCreateAppAttribution() + const payload = buildCreateAppEventPayload(params, externalAttribution) + + if (externalAttribution) + clearRememberedExternalCreateAppAttribution() + + trackEvent('create_app', payload) +}