mirror of
https://github.com/langgenius/dify.git
synced 2026-07-28 15:49:42 +08:00
fix(web): capture blog UTM/slug attribution reliably under CSP (#38022)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
parent
1dbda1463e
commit
35eeb743d1
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
62
web/app/components/external-attribution-recorder.tsx
Normal file
62
web/app/components/external-attribution-recorder.tsx
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user