From 35eeb743d1711fbb672fd153e16a45eb275760a6 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 26 Jun 2026 16:35:43 +0800 Subject: [PATCH] fix(web): capture blog UTM/slug attribution reliably under CSP (#38022) Co-authored-by: CodingOnStar --- .../create-app-attribution-bootstrap.spec.tsx | 90 ----------------- .../external-attribution-recorder.spec.tsx | 95 ++++++++++++++++++ .../create-app-attribution-bootstrap.tsx | 17 ---- .../external-attribution-recorder.tsx | 62 ++++++++++++ web/app/layout.tsx | 4 +- .../__tests__/create-app-tracking.spec.ts | 35 ++++--- web/utils/create-app-tracking.ts | 98 ++++--------------- 7 files changed, 198 insertions(+), 203 deletions(-) delete mode 100644 web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx create mode 100644 web/app/components/__tests__/external-attribution-recorder.spec.tsx delete mode 100644 web/app/components/create-app-attribution-bootstrap.tsx create mode 100644 web/app/components/external-attribution-recorder.tsx diff --git a/web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx b/web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx deleted file mode 100644 index 456d5cfa4c1..00000000000 --- a/web/app/components/__tests__/create-app-attribution-bootstrap.spec.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import type { ReactElement } 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') - return mod.CreateAppAttributionBootstrap -} - -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/__tests__/external-attribution-recorder.spec.tsx b/web/app/components/__tests__/external-attribution-recorder.spec.tsx new file mode 100644 index 00000000000..dbf16cffed6 --- /dev/null +++ b/web/app/components/__tests__/external-attribution-recorder.spec.tsx @@ -0,0 +1,95 @@ +import { render, waitFor } from '@testing-library/react' +import Cookies from 'js-cookie' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSearchParams } from '@/next/navigation' +import ExternalAttributionRecorder from '../external-attribution-recorder' + +const mockConfig = vi.hoisted(() => ({ IS_CLOUD_EDITION: true })) +const { mockRememberCreateAppExternalAttribution } = vi.hoisted(() => ({ + mockRememberCreateAppExternalAttribution: vi.fn(), +})) + +vi.mock('@/config', () => ({ + get IS_CLOUD_EDITION() { + return mockConfig.IS_CLOUD_EDITION + }, +})) + +vi.mock('@/next/navigation', () => ({ + useSearchParams: vi.fn(), +})) + +vi.mock('@/utils/create-app-tracking', () => ({ + rememberCreateAppExternalAttribution: (...args: unknown[]) => mockRememberCreateAppExternalAttribution(...args), +})) + +const mockUseSearchParams = vi.mocked(useSearchParams) + +const setSearchParams = (search = '') => { + mockUseSearchParams.mockReturnValue(new URLSearchParams(search) as unknown as ReturnType) +} + +const getUtmInfoCookie = () => { + const raw = Cookies.get('utm_info') + return raw ? JSON.parse(raw) : null +} + +describe('ExternalAttributionRecorder', () => { + beforeEach(() => { + vi.clearAllMocks() + Cookies.remove('utm_info') + mockConfig.IS_CLOUD_EDITION = true + setSearchParams() + }) + + it('seeds the utm_info cookie and create_app attribution from the landing url', async () => { + setSearchParams('utm_source=dify_blog&slug=get-started-with-dify') + + render() + + await waitFor(() => { + expect(getUtmInfoCookie()).toEqual({ + utm_source: 'dify_blog', + slug: 'get-started-with-dify', + }) + }) + expect(mockRememberCreateAppExternalAttribution).toHaveBeenCalledTimes(1) + const firstArg = mockRememberCreateAppExternalAttribution.mock.calls[0]?.[0] + expect(firstArg?.searchParams?.get('slug')).toBe('get-started-with-dify') + }) + + it('does nothing without a utm_source', () => { + setSearchParams('slug=get-started-with-dify') + + render() + + expect(getUtmInfoCookie()).toBeNull() + expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled() + }) + + it('overwrites a stale utm_info cookie with the latest campaign params', async () => { + Cookies.set('utm_info', JSON.stringify({ utm_source: 'newsletter', slug: 'launch-week' })) + setSearchParams('utm_source=dify_blog&slug=get-started-with-dify') + + render() + + // The most recent blog click wins, so a stale cookie can't shadow the new slug. + await waitFor(() => { + expect(getUtmInfoCookie()).toEqual({ + utm_source: 'dify_blog', + slug: 'get-started-with-dify', + }) + }) + expect(mockRememberCreateAppExternalAttribution).toHaveBeenCalledTimes(1) + }) + + it('is a no-op outside the cloud edition', () => { + mockConfig.IS_CLOUD_EDITION = false + setSearchParams('utm_source=dify_blog&slug=get-started-with-dify') + + render() + + expect(getUtmInfoCookie()).toBeNull() + expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/create-app-attribution-bootstrap.tsx b/web/app/components/create-app-attribution-bootstrap.tsx deleted file mode 100644 index dca95783280..00000000000 --- a/web/app/components/create-app-attribution-bootstrap.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { IS_PROD } from '@/config' -import { headers } from '@/next/headers' -import Script from '@/next/script' -import { buildCreateAppAttributionBootstrapScript } from '@/utils/create-app-tracking' - -export async function CreateAppAttributionBootstrap() { - const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? undefined : undefined - return ( - - ) -} diff --git a/web/app/components/external-attribution-recorder.tsx b/web/app/components/external-attribution-recorder.tsx new file mode 100644 index 00000000000..2a659dc8bbf --- /dev/null +++ b/web/app/components/external-attribution-recorder.tsx @@ -0,0 +1,62 @@ +'use client' + +import Cookies from 'js-cookie' +import { useEffect } from 'react' +import { IS_CLOUD_EDITION } from '@/config' +import { useSearchParams } from '@/next/navigation' +import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking' + +const UTM_INFO_COOKIE = 'utm_info' +const UTM_INFO_COOKIE_EXPIRES_DAYS = 1 +const UTM_INFO_QUERY_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'slug'] as const + +/** + * Captures external-campaign params (utm_* + blog `slug`) from the landing URL. + * + * Blog links point straight at cloud.dify.ai (e.g. `/apps?utm_source=dify_blog&slug=…`), + * bypassing the marketing site that normally seeds the `utm_info` cookie. A new visitor + * is bounced to sign-up, and the URL params are lost on that redirect — so we persist + * them here, on the landing render, before the redirect happens: + * + * - `utm_info` cookie → read by the registration trackers, so slug is reported on + * registration even when the user registers before creating an app. + * - create_app sessionStorage → read by `trackCreateApp`, so slug is reported on + * create_app. + * + * slug is intentionally NOT attached to page-view events; only these conversion events. + */ +const ExternalAttributionRecorder = () => { + const searchParams = useSearchParams() + + useEffect(() => { + if (!IS_CLOUD_EDITION) + return + + const utmSource = searchParams.get('utm_source')?.trim() + if (!utmSource) + return + + // create_app conversion attribution (utm_source + slug). + rememberCreateAppExternalAttribution({ searchParams }) + + // Seed the utm_info cookie the registration trackers read. A campaign click always + // overwrites any previous value, so the most recent blog link wins (last-touch) and + // a stale cookie from an earlier, un-converted visit can't shadow the new slug. This + // mirrors the create_app attribution refreshed just above. + const utmInfo: Record = {} + UTM_INFO_QUERY_KEYS.forEach((key) => { + const value = searchParams.get(key)?.trim() + if (value) + utmInfo[key] = value + }) + + Cookies.set(UTM_INFO_COOKIE, JSON.stringify(utmInfo), { + expires: UTM_INFO_COOKIE_EXPIRES_DAYS, + path: '/', + }) + }, [searchParams]) + + return null +} + +export default ExternalAttributionRecorder diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 7eaf7b8f432..9b59cb8e11f 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -10,9 +10,9 @@ import { getDatasetMap } from '@/env' import { getLocaleOnServer } from '@/i18n-config/server' import { headers } from '@/next/headers' 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 ExternalAttributionRecorder from './components/external-attribution-recorder' import { I18nServerProvider } from './components/provider/i18n-server' import RoutePrefixHandle from './routePrefixHandle' import './styles/globals.css' @@ -48,7 +48,6 @@ const LocaleLayout = async ({ - + {children} diff --git a/web/utils/__tests__/create-app-tracking.spec.ts b/web/utils/__tests__/create-app-tracking.spec.ts index 67e641811ef..5594ff29d33 100644 --- a/web/utils/__tests__/create-app-tracking.spec.ts +++ b/web/utils/__tests__/create-app-tracking.spec.ts @@ -18,28 +18,33 @@ describe('create-app-tracking', () => { }) describe('extractExternalCreateAppAttribution', () => { - it('should map campaign links to external attribution', () => { + it('should keep the raw utm_source and report slug under its own field', () => { 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', + utmSource: 'x', + slug: 'how-to-build-rag-agent', }) }) - it('should map newsletter and blog sources to blog', () => { + it('should gate on known external sources but keep raw values', () => { expect(extractExternalCreateAppAttribution({ searchParams: new URLSearchParams('utm_source=newsletter'), - })).toEqual({ utmSource: 'blog' }) + })).toEqual({ utmSource: 'newsletter' }) expect(extractExternalCreateAppAttribution({ utmInfo: { utm_source: 'dify_blog', slug: 'launch-week' }, })).toEqual({ - utmSource: 'blog', - utmCampaign: 'launch-week', + utmSource: 'dify_blog', + slug: 'launch-week', }) + + // Unknown sources are still rejected. + expect(extractExternalCreateAppAttribution({ + searchParams: new URLSearchParams('utm_source=random&slug=x'), + })).toBeNull() }) }) @@ -130,7 +135,7 @@ describe('create-app-tracking', () => { }, { utmSource: 'linkedin', - utmCampaign: 'agent-launch', + slug: 'agent-launch', }, new Date(2026, 3, 13, 7, 6, 5), )).toEqual({ @@ -139,7 +144,7 @@ describe('create-app-tracking', () => { time: '04-13-07:06:05', template_id: 'template-1', utm_source: 'linkedin', - utm_campaign: 'agent-launch', + slug: 'agent-launch', }) }) @@ -164,8 +169,8 @@ describe('create-app-tracking', () => { 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', + utm_source: 'newsletter', + slug: 'how-to-build-rag-agent', }) trackCreateApp({ source: 'studio_template_list', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' }) @@ -195,7 +200,7 @@ describe('create-app-tracking', () => { time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/), template_id: 'template-2', utm_source: 'linkedin', - utm_campaign: 'agent-launch', + slug: 'agent-launch', }) }) @@ -224,7 +229,7 @@ describe('create-app-tracking', () => { } }) - it('should read, normalize, and consume snake_case sessionStorage attribution', () => { + it('should consume snake_case sessionStorage attribution and map legacy utm_campaign to slug', () => { window.sessionStorage.setItem('create_app_external_attribution', JSON.stringify({ utm_source: 'twitter', utm_campaign: 'launch-week', @@ -236,8 +241,8 @@ describe('create-app-tracking', () => { 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', + utm_source: 'twitter', + slug: 'launch-week', }) expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull() }) diff --git a/web/utils/create-app-tracking.ts b/web/utils/create-app-tracking.ts index 800171c8945..f35283187ea 100644 --- a/web/utils/create-app-tracking.ts +++ b/web/utils/create-app-tracking.ts @@ -3,7 +3,6 @@ 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', @@ -37,12 +36,11 @@ export type TrackCreateAppParams = { } 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(/ { @@ -101,65 +99,6 @@ const mapOriginalCreateAppMode = (appMode: AppModeEnum): OriginalCreateAppMode = 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, @@ -168,36 +107,37 @@ export const extractExternalCreateAppAttribution = ({ utmInfo?: Record | null }) => { const rawSource = getSearchParamValue(searchParams, 'utm_source') ?? getObjectStringValue(utmInfo?.utm_source) - const mappedSource = mapExternalUtmSource(rawSource) - if (!mappedSource) + // Gate on known external sources, but keep the raw value for reporting. + if (!rawSource || !mapExternalUtmSource(rawSource)) return null - const utmCampaign = getSearchParamValue(searchParams, 'slug') + const slug = getSearchParamValue(searchParams, 'slug') ?? getSearchParamValue(searchParams, 'utm_campaign') ?? getObjectStringValue(utmInfo?.slug) ?? getObjectStringValue(utmInfo?.utm_campaign) return { - utmSource: mappedSource, - ...(utmCampaign ? { utmCampaign } : {}), + utmSource: rawSource, + ...(slug ? { slug } : {}), } satisfies ExternalCreateAppAttribution } const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribution | null => { const attribution = parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)) - const utmSource = mapExternalUtmSource( - getObjectStringValue(attribution?.utmSource) ?? getObjectStringValue(attribution?.utm_source), - ) + const rawSource = getObjectStringValue(attribution?.utmSource) ?? getObjectStringValue(attribution?.utm_source) - if (!utmSource) + // Gate on known external sources, but keep the raw value for reporting. + if (!rawSource || !mapExternalUtmSource(rawSource)) return null - const utmCampaign = getObjectStringValue(attribution?.utmCampaign) ?? getObjectStringValue(attribution?.utm_campaign) + const slug = getObjectStringValue(attribution?.slug) + ?? getObjectStringValue(attribution?.utmCampaign) + ?? getObjectStringValue(attribution?.utm_campaign) return { - utmSource, - ...(utmCampaign ? { utmCampaign } : {}), + utmSource: rawSource, + ...(slug ? { slug } : {}), } } @@ -252,7 +192,7 @@ export const buildCreateAppEventPayload = ( ...(externalAttribution ? { utm_source: externalAttribution.utmSource, - ...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}), + ...(externalAttribution.slug ? { slug: externalAttribution.slug } : {}), } : {}), } satisfies Record