fix(web): capture blog UTM/slug attribution reliably under CSP (#38022)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star 2026-06-26 16:35:43 +08:00 committed by GitHub
parent 1dbda1463e
commit 35eeb743d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 198 additions and 203 deletions

View File

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

View File

@ -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<typeof useSearchParams>)
}
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(<ExternalAttributionRecorder />)
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(<ExternalAttributionRecorder />)
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(<ExternalAttributionRecorder />)
// 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(<ExternalAttributionRecorder />)
expect(getUtmInfoCookie()).toBeNull()
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
})
})

View File

@ -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 (
<Script
id="create-app-attribution-bootstrap"
strategy="beforeInteractive"
nonce={nonce}
>
{buildCreateAppAttributionBootstrapScript()}
</Script>
)
}

View File

@ -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<string, string> = {}
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

View File

@ -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 ({
<meta name="msapplication-TileColor" content="#1C64F2" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<CreateAppAttributionBootstrap />
<ReactScanLoader />
</head>
<body
@ -69,6 +68,7 @@ const LocaleLayout = async ({
<I18nServerProvider>
<ToastHost timeout={5000} limit={3} />
<PartnerStackCookieRecorder />
<ExternalAttributionRecorder />
<TooltipProvider delay={300} closeDelay={200}>
{children}
</TooltipProvider>

View File

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

View File

@ -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(/</g, '\\u003c')
// Raw utm_source from the link (e.g. "dify_blog"), reported as-is to stay consistent
// with the registration event. EXTERNAL_UTM_SOURCE_MAP is only used to gate which
// sources count as external, not to rewrite the reported value.
utmSource: string
slug?: string
}
const normalizeString = (value?: string | null) => {
@ -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<string, unknown> | 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<string, string>