fix: separate WebApp analytics from console page tracking (#41251)

This commit is contained in:
Joel authored and GitHub committed 2026-08-26 03:02:38 +00:00
1 parent 6b5952425d
commit dfe396fb64
28 files changed
+665 -218

No files matched your search

@@ -25,6 +25,8 @@ export function AmplitudeProvider({
sessionReplaySampleRate,
})
setAmplitudeOptOut(false)
return () => setAmplitudeOptOut(true)
}, [active, consent, sessionReplaySampleRate])
return null
@@ -0,0 +1,27 @@
'use client'
import { useEffect } from 'react'
import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
import { sendWebAppAmplitudeEvent, setWebAppAmplitudeOptOut } from './web-app-client'
import { registerWebAppEventTracker } from './web-app-event'
export function WebAppAmplitudeProvider() {
const consent = useAnalyticsConsent()
useEffect(() => {
if (consent !== 'granted') {
setWebAppAmplitudeOptOut(true)
return
}
setWebAppAmplitudeOptOut(false)
const unregisterTracker = registerWebAppEventTracker(sendWebAppAmplitudeEvent)
return () => {
unregisterTracker()
setWebAppAmplitudeOptOut(true)
}
}, [consent])
return null
}
@@ -100,10 +100,10 @@ describe('AmplitudeProvider', () => {
expect(amplitude.add).toHaveBeenCalledTimes(2)
})
it('opts out when the runtime leaves the first-party route boundary', () => {
const { rerender } = render(<AmplitudeProvider active />)
it('opts out when the analytics layout runtime unmounts', () => {
const { unmount } = render(<AmplitudeProvider />)
rerender(<AmplitudeProvider active={false} />)
unmount()
expect(amplitude.setOptOut).toHaveBeenLastCalledWith(true)
})
@@ -145,6 +145,9 @@ describe('AmplitudeProvider', () => {
window.location.pathname = '/apps'
await execute(event)
expect(getPageTitle(event)).toBe('Studio')
window.location.pathname = '/agents'
await execute(event)
expect(getPageTitle(event)).toBe('Agents')
window.location.pathname = '/explore'
await execute(event)
expect(getPageTitle(event)).toBe('Explore')
@@ -0,0 +1,74 @@
import { render } from '@testing-library/react'
import { trackWebAppEvent } from '../web-app-event'
import { WebAppAmplitudeProvider } from '../WebAppAmplitudeProvider'
const { mockClient, mockConsent } = vi.hoisted(() => ({
mockClient: {
ensureInitialized: vi.fn(),
sendEvent: vi.fn(),
setOptOut: vi.fn(),
},
mockConsent: {
value: 'unknown' as 'unknown' | 'denied' | 'granted',
},
}))
vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
useAnalyticsConsent: () => mockConsent.value,
}))
vi.mock('../web-app-client', () => ({
ensureWebAppAmplitudeInitialized: () => mockClient.ensureInitialized(),
sendWebAppAmplitudeEvent: (...args: unknown[]) => mockClient.sendEvent(...args),
setWebAppAmplitudeOptOut: (optOut: boolean) => mockClient.setOptOut(optOut),
}))
describe('WebAppAmplitudeProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockClient.ensureInitialized.mockReset()
mockClient.ensureInitialized.mockReturnValue(Promise.resolve())
mockConsent.value = 'unknown'
})
it('registers explicit tracking only while analytics consent is granted', () => {
const { rerender } = render(<WebAppAmplitudeProvider />)
trackWebAppEvent('webapp_run', { app_mode: 'agent-v2' })
expect(mockClient.ensureInitialized).not.toHaveBeenCalled()
expect(mockClient.sendEvent).not.toHaveBeenCalled()
mockClient.ensureInitialized.mockReturnValue(new Promise(() => {}))
mockConsent.value = 'granted'
rerender(<WebAppAmplitudeProvider />)
expect(mockClient.ensureInitialized).not.toHaveBeenCalled()
trackWebAppEvent('webapp_run', { app_mode: 'agent-v2' })
expect(mockClient.setOptOut).toHaveBeenLastCalledWith(false)
expect(mockClient.sendEvent).toHaveBeenCalledWith('webapp_run', {
app_mode: 'agent-v2',
})
mockConsent.value = 'denied'
rerender(<WebAppAmplitudeProvider />)
mockClient.sendEvent.mockClear()
trackWebAppEvent('webapp_run', { app_mode: 'chatflow' })
expect(mockClient.setOptOut).toHaveBeenLastCalledWith(true)
expect(mockClient.sendEvent).not.toHaveBeenCalled()
})
it('unregisters tracking and opts out when the WebApp layout unmounts', () => {
mockConsent.value = 'granted'
const { unmount } = render(<WebAppAmplitudeProvider />)
unmount()
mockClient.sendEvent.mockClear()
trackWebAppEvent('webapp_run', { app_mode: 'workflow' })
expect(mockClient.setOptOut).toHaveBeenLastCalledWith(true)
expect(mockClient.sendEvent).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,165 @@
import type { Types } from '@amplitude/analytics-browser'
const TEST_API_KEY = 'test-api-key'
const CONSOLE_IDENTITY_COOKIE_NAME = `AMP_${TEST_API_KEY.slice(0, 10)}`
const CONSOLE_UNSENT_EVENTS_KEY = `AMP_unsent_${TEST_API_KEY.slice(0, 10)}`
const { mockConfig, mockCreateInstance, mockIdentity, mockTrackedEvents, mockWebAppClient } =
vi.hoisted(() => {
const identity = {
userId: undefined as string | undefined,
}
const trackedEvents: Array<{
eventName: string
properties: Record<string, unknown>
userId: string | undefined
}> = []
const mockWebAppClient = {
init: vi.fn((apiKey: string, options?: Types.BrowserOptions) => {
const identityCookieName = `AMP_${apiKey.slice(0, 10)}=`
const identityCookie = document.cookie
.split(';')
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(identityCookieName))
identity.userId =
options?.identityStorage === 'none'
? undefined
: identityCookie
? decodeURIComponent(identityCookie.slice(identityCookieName.length))
: undefined
}),
setOptOut: vi.fn(),
track: vi.fn((eventName: string, properties: Record<string, unknown>) => {
trackedEvents.push({ eventName, properties, userId: identity.userId })
}),
}
return {
mockConfig: {
AMPLITUDE_API_KEY: 'test-api-key',
},
mockCreateInstance: vi.fn(() => mockWebAppClient),
mockWebAppClient,
mockIdentity: identity,
mockTrackedEvents: trackedEvents,
}
})
let ensureWebAppAmplitudeInitialized: typeof import('../web-app-client').ensureWebAppAmplitudeInitialized
let sendWebAppAmplitudeEvent: typeof import('../web-app-client').sendWebAppAmplitudeEvent
let setWebAppAmplitudeOptOut: typeof import('../web-app-client').setWebAppAmplitudeOptOut
vi.mock('@/config', () => ({
get AMPLITUDE_API_KEY() {
return mockConfig.AMPLITUDE_API_KEY
},
}))
vi.mock('@amplitude/analytics-browser', () => ({
createInstance: mockCreateInstance,
}))
describe('WebApp Amplitude client', () => {
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
localStorage.clear()
document.cookie = `${CONSOLE_IDENTITY_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
mockConfig.AMPLITUDE_API_KEY = TEST_API_KEY
mockIdentity.userId = undefined
mockTrackedEvents.length = 0
;({ ensureWebAppAmplitudeInitialized, sendWebAppAmplitudeEvent, setWebAppAmplitudeOptOut } =
await import('../web-app-client'))
})
it('loads the SDK lazily and initializes one dedicated instance', async () => {
expect(mockCreateInstance).not.toHaveBeenCalled()
await Promise.all([ensureWebAppAmplitudeInitialized(), ensureWebAppAmplitudeInitialized()])
expect(mockCreateInstance).toHaveBeenCalledTimes(1)
expect(mockWebAppClient.init).toHaveBeenCalledTimes(1)
expect(mockWebAppClient.init).toHaveBeenCalledWith('test-api-key', {
autocapture: false,
defaultTracking: false,
fetchRemoteConfig: false,
flushQueueSize: 1,
identityStorage: 'none',
instanceName: 'webapp',
optOut: true,
storageProvider: expect.any(Object),
})
})
it('preserves the first consented event while the SDK initializes', async () => {
setWebAppAmplitudeOptOut(false)
const pendingEvent = sendWebAppAmplitudeEvent('webapp_run', { app_mode: 'agent-v2' })
expect(mockWebAppClient.track).not.toHaveBeenCalled()
await pendingEvent
expect(mockWebAppClient.init).toHaveBeenCalledWith(
TEST_API_KEY,
expect.objectContaining({ optOut: false }),
)
expect(mockWebAppClient.setOptOut).not.toHaveBeenCalled()
expect(mockWebAppClient.track).toHaveBeenCalledWith('webapp_run', {
app_mode: 'agent-v2',
})
})
it('drops a pending event when consent is withdrawn before initialization completes', async () => {
setWebAppAmplitudeOptOut(false)
const pendingEvent = sendWebAppAmplitudeEvent('webapp_run', { app_mode: 'workflow' })
setWebAppAmplitudeOptOut(true)
await pendingEvent
expect(mockWebAppClient.init).toHaveBeenCalledWith(
TEST_API_KEY,
expect.objectContaining({ optOut: true }),
)
expect(mockWebAppClient.setOptOut).not.toHaveBeenCalled()
expect(mockWebAppClient.track).not.toHaveBeenCalled()
})
it('does not inherit the console identity or unsent-event queue', async () => {
document.cookie = `${CONSOLE_IDENTITY_COOKIE_NAME}=${encodeURIComponent('builder@example.com')}; path=/`
localStorage.setItem(
CONSOLE_UNSENT_EVENTS_KEY,
JSON.stringify([{ event_type: 'console_event', user_id: 'builder@example.com' }]),
)
setWebAppAmplitudeOptOut(false)
await sendWebAppAmplitudeEvent('webapp_run', { app_mode: 'workflow' })
expect(mockTrackedEvents).toEqual([
{
eventName: 'webapp_run',
properties: { app_mode: 'workflow' },
userId: undefined,
},
])
const initOptions = mockWebAppClient.init.mock.calls[0]?.[1]
const storageProvider = initOptions?.storageProvider
expect(storageProvider).toBeDefined()
await expect(storageProvider?.get(CONSOLE_UNSENT_EVENTS_KEY)).resolves.toBeUndefined()
await storageProvider?.set(CONSOLE_UNSENT_EVENTS_KEY, [])
await expect(storageProvider?.get(CONSOLE_UNSENT_EVENTS_KEY)).resolves.toEqual([])
expect(localStorage.getItem(CONSOLE_UNSENT_EVENTS_KEY)).not.toBeNull()
expect(localStorage).toHaveLength(1)
})
it('does not initialize when Amplitude is disabled', async () => {
mockConfig.AMPLITUDE_API_KEY = ''
await ensureWebAppAmplitudeInitialized()
await sendWebAppAmplitudeEvent('webapp_run', { app_mode: 'chatflow' })
expect(mockWebAppClient.init).not.toHaveBeenCalled()
expect(mockWebAppClient.track).not.toHaveBeenCalled()
})
})
@@ -29,6 +29,7 @@ const getEnglishPageName = (pathname: string): string => {
const pageNameMap: Record<string, string> = {
'': 'Home',
apps: 'Studio',
agents: 'Agents',
datasets: 'Knowledge',
explore: 'Explore',
tools: 'Tools',
@@ -0,0 +1,85 @@
import type { Types } from '@amplitude/analytics-browser'
import type { WebAppEventName, WebAppEventProperties } from './web-app-event'
import { AMPLITUDE_API_KEY } from '@/config'
type WebAppAmplitudeClient = ReturnType<
(typeof import('@amplitude/analytics-browser'))['createInstance']
>
type WebAppAmplitudeStorageProvider = NonNullable<Types.BrowserOptions['storageProvider']>
const webAppAmplitudeEventQueue = new Map<string, Types.Event[]>()
const webAppAmplitudeStorageProvider: WebAppAmplitudeStorageProvider = {
async isEnabled() {
return true
},
async get(key) {
return webAppAmplitudeEventQueue.get(key)
},
async getRaw(key) {
const value = webAppAmplitudeEventQueue.get(key)
return value ? JSON.stringify(value) : undefined
},
async set(key, value) {
webAppAmplitudeEventQueue.set(key, value)
},
async remove(key) {
webAppAmplitudeEventQueue.delete(key)
},
async reset() {
webAppAmplitudeEventQueue.clear()
},
}
let webAppAmplitude: WebAppAmplitudeClient | null = null
let webAppAmplitudeInitialization: Promise<WebAppAmplitudeClient | null> | null = null
let shouldWebAppAmplitudeOptOut = true
async function initializeWebAppAmplitude(): Promise<WebAppAmplitudeClient | null> {
try {
const { createInstance } = await import('@amplitude/analytics-browser')
const client = createInstance()
client.init(AMPLITUDE_API_KEY, {
autocapture: false,
defaultTracking: false,
fetchRemoteConfig: false,
flushQueueSize: 1,
identityStorage: 'none',
instanceName: 'webapp',
optOut: shouldWebAppAmplitudeOptOut,
storageProvider: webAppAmplitudeStorageProvider,
})
webAppAmplitude = client
return client
} catch {
webAppAmplitudeInitialization = null
return null
}
}
export function ensureWebAppAmplitudeInitialized() {
if (!AMPLITUDE_API_KEY) return Promise.resolve(null)
if (!webAppAmplitudeInitialization) webAppAmplitudeInitialization = initializeWebAppAmplitude()
return webAppAmplitudeInitialization
}
export function setWebAppAmplitudeOptOut(optOut: boolean) {
shouldWebAppAmplitudeOptOut = optOut
webAppAmplitude?.setOptOut(optOut)
}
export async function sendWebAppAmplitudeEvent<EventName extends WebAppEventName>(
eventName: EventName,
properties: WebAppEventProperties[EventName],
) {
if (shouldWebAppAmplitudeOptOut) return
const client = await ensureWebAppAmplitudeInitialized()
if (!client || shouldWebAppAmplitudeOptOut) return
client.track(eventName, properties)
}
@@ -0,0 +1,29 @@
export type WebAppEventProperties = {
webapp_run: {
app_mode: string
}
}
export type WebAppEventName = keyof WebAppEventProperties
export type WebAppEventTracker = <EventName extends WebAppEventName>(
eventName: EventName,
properties: WebAppEventProperties[EventName],
) => void
let activeTracker: WebAppEventTracker | null = null
export function registerWebAppEventTracker(tracker: WebAppEventTracker) {
activeTracker = tracker
return () => {
if (activeTracker === tracker) activeTracker = null
}
}
export function trackWebAppEvent<EventName extends WebAppEventName>(
eventName: EventName,
properties: WebAppEventProperties[EventName],
) {
activeTracker?.(eventName, properties)
}
@@ -2,10 +2,12 @@
This module owns CookieYes consent state and the Dify Cloud analytics boundary.
- `request-boundary.ts` decides whether a request is eligible for Cloud analytics before client code mounts.
- `request-boundary.ts` decides whether the deployment, environment, and host are eligible for Cloud analytics before client code mounts.
- `cloud-analytics-layout-boundary.tsx` uses the active Next.js route group to select the console or WebApp analytics scripts and runtime without maintaining URL path exclusions. Google Analytics is mounted only for the console branch.
- `cookieyes-consent-bridge.tsx` is the only CookieYes-to-application state adapter.
- `consent-store.ts` owns the client consent snapshot; analytics consumers must gate SDK activation or event emission on that snapshot.
- `cloud-analytics.tsx` gates analytics scripts and runtime mounting by deployment, environment, host, and route.
- `cloud-analytics-runtime.tsx` mounts the consent bridge and client analytics consumers.
- `cloud-analytics.tsx` gates analytics scripts and runtime mounting by deployment, environment, and host.
- `console-analytics-runtime.tsx` mounts the full Amplitude configuration and external-attribution consumers.
- `web-app-analytics-runtime.tsx` mounts consent and the dedicated custom-event-only Amplitude provider. It must not mount Google Analytics, automatic Amplitude tracking, session replay, or external attribution.
Amplitude, Google Analytics, attribution, and other consumers must depend on this boundary instead of reading CookieYes state directly.
Amplitude, Google Analytics, attribution, and other consumers must depend on these boundaries instead of reading CookieYes state directly. WebApp components must use `trackWebAppEvent` rather than the console `trackEvent` API.
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react'
import { ConsoleAnalyticsRuntime } from '../console-analytics-runtime'
import { WebAppAnalyticsRuntime } from '../web-app-analytics-runtime'
vi.mock('../cookieyes-consent-bridge', () => ({
CookieYesConsentBridge: () => <span data-testid="cookieyes-consent-bridge" />,
}))
vi.mock('@/app/components/base/amplitude', () => ({
default: () => <span data-testid="console-amplitude-provider" />,
}))
vi.mock('@/app/components/base/amplitude/WebAppAmplitudeProvider', () => ({
WebAppAmplitudeProvider: () => <span data-testid="web-app-amplitude-provider" />,
}))
vi.mock('@/app/components/external-attribution-recorder', () => ({
default: () => <span data-testid="external-attribution-recorder" />,
}))
describe('analytics runtimes', () => {
it('mounts full analytics consumers for the console', () => {
render(<ConsoleAnalyticsRuntime />)
expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument()
expect(screen.getByTestId('console-amplitude-provider')).toBeInTheDocument()
expect(screen.getByTestId('external-attribution-recorder')).toBeInTheDocument()
expect(screen.queryByTestId('web-app-amplitude-provider')).toBeNull()
})
it('mounts only consent and custom-event Amplitude for WebApps', () => {
render(<WebAppAnalyticsRuntime />)
expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument()
expect(screen.getByTestId('web-app-amplitude-provider')).toBeInTheDocument()
expect(screen.queryByTestId('console-amplitude-provider')).toBeNull()
expect(screen.queryByTestId('external-attribution-recorder')).toBeNull()
})
})
@@ -0,0 +1,100 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { CloudAnalyticsLayoutBoundary } from '../cloud-analytics-layout-boundary'
const mockState = vi.hoisted(() => ({
layoutSegments: ['(commonLayout)', 'agents'],
}))
vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegments: () => mockState.layoutSegments,
}))
vi.mock('@/next/script', () => ({
default: ({
id,
strategy,
src,
nonce,
children,
}: {
id?: string
strategy?: string
src?: string
nonce?: string
children?: ReactNode
}) => (
<script
data-id={id ?? ''}
data-inline={typeof children === 'string' ? children : ''}
data-nonce={nonce ?? ''}
data-src={src ?? ''}
data-strategy={strategy ?? ''}
/>
),
}))
vi.mock('../console-analytics-runtime', () => ({
ConsoleAnalyticsRuntime: () => <span data-testid="console-analytics-runtime" />,
}))
vi.mock('../web-app-analytics-runtime', () => ({
WebAppAnalyticsRuntime: () => <span data-testid="web-app-analytics-runtime" />,
}))
describe('CloudAnalyticsLayoutBoundary', () => {
beforeEach(() => {
mockState.layoutSegments = ['(commonLayout)', 'agents']
})
it('mounts analytics for the console layout, including the agents list', () => {
const { container } = render(
<CloudAnalyticsLayoutBoundary cookieYesSiteKey="site-key" nonce="test-nonce" />,
)
const scripts = Array.from(container.querySelectorAll('script'))
const scriptIds = scripts.map(
(script) => script.getAttribute('id') || script.getAttribute('data-id'),
)
expect(scriptIds).toEqual([
'google-consent-defaults',
'cookieyes',
'google-analytics',
'google-analytics-init',
])
expect(container.querySelector('script[data-id="cookieyes"]')).toHaveAttribute(
'data-src',
'https://cdn-cookieyes.com/client_data/site-key/script.js',
)
expect(screen.getByTestId('console-analytics-runtime')).toBeInTheDocument()
expect(screen.queryByTestId('web-app-analytics-runtime')).toBeNull()
})
it('mounts only consent and custom-event analytics for the published WebApp layout', () => {
mockState.layoutSegments = ['(shareLayout)', 'agent', 'token']
const { container } = render(
<CloudAnalyticsLayoutBoundary cookieYesSiteKey="site-key" nonce="test-nonce" />,
)
const scriptIds = Array.from(container.querySelectorAll('script')).map(
(script) => script.getAttribute('id') || script.getAttribute('data-id'),
)
expect(scriptIds).toEqual(['cookieyes'])
expect(screen.queryByTestId('console-analytics-runtime')).toBeNull()
expect(screen.getByTestId('web-app-analytics-runtime')).toBeInTheDocument()
})
it('switches analytics runtimes when navigation enters the WebApp layout', () => {
const { rerender } = render(
<CloudAnalyticsLayoutBoundary cookieYesSiteKey="site-key" nonce="test-nonce" />,
)
expect(screen.getByTestId('console-analytics-runtime')).toBeInTheDocument()
mockState.layoutSegments = ['(shareLayout)', 'workflow', 'token']
rerender(<CloudAnalyticsLayoutBoundary cookieYesSiteKey="site-key" nonce="test-nonce" />)
expect(screen.queryByTestId('console-analytics-runtime')).toBeNull()
expect(screen.getByTestId('web-app-analytics-runtime')).toBeInTheDocument()
})
})
@@ -1,50 +0,0 @@
import { render, screen } from '@testing-library/react'
import { CloudAnalyticsRuntime } from '../cloud-analytics-runtime'
const mockState = vi.hoisted(() => ({ pathname: '/signin' }))
vi.mock('@/next/navigation', () => ({
usePathname: () => mockState.pathname,
}))
vi.mock('../cookieyes-consent-bridge', () => ({
CookieYesConsentBridge: () => <span data-testid="cookieyes-consent-bridge" />,
}))
vi.mock('@/app/components/base/amplitude', () => ({
default: ({ active }: { active: boolean }) => (
<span data-active={String(active)} data-testid="amplitude-provider" />
),
}))
vi.mock('@/app/components/external-attribution-recorder', () => ({
default: () => <span data-testid="external-attribution-recorder" />,
}))
describe('CloudAnalyticsRuntime', () => {
beforeEach(() => {
mockState.pathname = '/signin'
})
it('keeps consent active across first-party Cloud routes', () => {
const { rerender } = render(<CloudAnalyticsRuntime />)
expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument()
expect(screen.getByTestId('external-attribution-recorder')).toBeInTheDocument()
expect(screen.getByTestId('amplitude-provider')).toHaveAttribute('data-active', 'true')
mockState.pathname = '/integrations'
rerender(<CloudAnalyticsRuntime />)
expect(screen.getByTestId('amplitude-provider')).toHaveAttribute('data-active', 'true')
})
it('opts Amplitude out after a client transition into a share route', () => {
const { rerender } = render(<CloudAnalyticsRuntime />)
mockState.pathname = '/workflow/token'
rerender(<CloudAnalyticsRuntime />)
expect(screen.getByTestId('amplitude-provider')).toHaveAttribute('data-active', 'false')
})
})
@@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import { QueryClient } from '@tanstack/react-query'
import { render } from '@testing-library/react'
@@ -36,36 +35,24 @@ vi.mock('@/config', () => ({
},
}))
vi.mock('@/next/script', () => ({
default: ({
id,
strategy,
src,
nonce,
children,
}: {
id?: string
strategy?: string
src?: string
nonce?: string
children?: ReactNode
}) => (
<script
data-id={id ?? ''}
data-inline={typeof children === 'string' ? children : ''}
data-nonce={nonce ?? ''}
data-src={src ?? ''}
data-strategy={strategy ?? ''}
/>
),
}))
vi.mock('@/next/headers', () => ({
headers: async () => ({ get: mockHeadersGet }),
}))
vi.mock('../cloud-analytics-runtime', () => ({
CloudAnalyticsRuntime: () => <span data-testid="cloud-analytics-runtime" />,
vi.mock('../cloud-analytics-layout-boundary', () => ({
CloudAnalyticsLayoutBoundary: ({
cookieYesSiteKey,
nonce,
}: {
cookieYesSiteKey: string
nonce?: string
}) => (
<span
data-cookieyes-site-key={cookieYesSiteKey}
data-nonce={nonce}
data-testid="cloud-analytics-layout-boundary"
/>
),
}))
async function renderCloudAnalytics() {
@@ -85,89 +72,48 @@ describe('CloudAnalytics', () => {
mockHeadersGet.mockImplementation((name: string) => {
const values: Record<string, string> = {
host: 'cloud.dify.ai',
'x-dify-pathname': '/signin',
'x-nonce': 'test-nonce',
}
return values[name] ?? null
})
})
it('renders analytics scripts before interaction and mounts the runtime', async () => {
const { container, getByTestId } = await renderCloudAnalytics()
const scripts = Array.from(container.querySelectorAll('script'))
const scriptIds = scripts.map(
(script) => script.getAttribute('id') || script.getAttribute('data-id'),
)
it('mounts the layout boundary for an eligible Cloud request', async () => {
const { getByTestId } = await renderCloudAnalytics()
const boundary = getByTestId('cloud-analytics-layout-boundary')
expect(scriptIds).toEqual([
'google-consent-defaults',
'cookieyes',
'google-analytics',
'google-analytics-init',
])
const consentDefaultsScript = container.querySelector(
'script[data-id="google-consent-defaults"]',
)
expect(consentDefaultsScript).toHaveAttribute('data-strategy', 'beforeInteractive')
const cookieYesScript = container.querySelector('script[data-id="cookieyes"]')
expect(cookieYesScript).toHaveAttribute(
'data-src',
'https://cdn-cookieyes.com/client_data/site-key/script.js',
)
expect(cookieYesScript).toHaveAttribute('data-strategy', 'beforeInteractive')
expect(cookieYesScript).toHaveAttribute('data-nonce', 'test-nonce')
expect(getByTestId('cloud-analytics-runtime')).toBeInTheDocument()
})
it('does not render on a published-app path', async () => {
mockHeadersGet.mockImplementation((name: string) => {
const values: Record<string, string> = {
host: 'cloud.dify.ai',
'x-dify-pathname': '/chat/token',
}
return values[name] ?? null
})
const { container, queryByTestId } = await renderCloudAnalytics()
expect(container.querySelector('script')).toBeNull()
expect(queryByTestId('cloud-analytics-runtime')).toBeNull()
expect(boundary).toHaveAttribute('data-cookieyes-site-key', 'site-key')
expect(boundary).toHaveAttribute('data-nonce', 'test-nonce')
})
it('does not render on a different host', async () => {
mockHeadersGet.mockImplementation((name: string) => {
const values: Record<string, string> = {
host: 'customer.example.com',
'x-dify-pathname': '/signin',
}
return values[name] ?? null
})
const { container, queryByTestId } = await renderCloudAnalytics()
const { queryByTestId } = await renderCloudAnalytics()
expect(container.querySelector('script')).toBeNull()
expect(queryByTestId('cloud-analytics-runtime')).toBeNull()
expect(queryByTestId('cloud-analytics-layout-boundary')).toBeNull()
})
it.each(['COMMUNITY', 'ENTERPRISE'] as const)(
'disables analytics when deployment edition is %s',
async (deploymentEdition) => {
queryClient.setQueryData(systemFeaturesQueryKey, { deployment_edition: deploymentEdition })
const { container, queryByTestId } = await renderCloudAnalytics()
const { queryByTestId } = await renderCloudAnalytics()
expect(container.querySelector('script')).toBeNull()
expect(queryByTestId('cloud-analytics-runtime')).toBeNull()
expect(queryByTestId('cloud-analytics-layout-boundary')).toBeNull()
},
)
it('does not render when System Features are unavailable', async () => {
queryClient.removeQueries({ queryKey: systemFeaturesQueryKey })
const { container, queryByTestId } = await renderCloudAnalytics()
const { queryByTestId } = await renderCloudAnalytics()
expect(container.querySelector('script')).toBeNull()
expect(queryByTestId('cloud-analytics-runtime')).toBeNull()
expect(queryByTestId('cloud-analytics-layout-boundary')).toBeNull()
})
})
@@ -1,36 +1,16 @@
import { isCloudAnalyticsPath, isCloudAnalyticsRequest } from '../request-boundary'
import { isCloudAnalyticsRequest } from '../request-boundary'
const baseRequest = {
cookieYesSiteKey: 'site-key',
deploymentEdition: 'CLOUD',
isProd: true,
pathname: '/signin',
requestHost: 'cloud.dify.ai',
webPrefix: 'https://cloud.dify.ai',
} as const
describe('cloud analytics request boundary', () => {
it('enables first-party Cloud authentication and console routes', () => {
it('enables first-party Cloud requests', () => {
expect(isCloudAnalyticsRequest(baseRequest)).toBe(true)
expect(isCloudAnalyticsPath('/apps')).toBe(true)
expect(isCloudAnalyticsPath('/integrations')).toBe(true)
})
it.each([
'/agent/token',
'/chat/token',
'/chatbot/token',
'/completion/token',
'/workflow/token',
'/webapp-signin',
'/webapp-reset-password/check-code',
])('excludes published and embeddable path %s', (pathname) => {
expect(isCloudAnalyticsPath(pathname)).toBe(false)
expect(isCloudAnalyticsRequest({ ...baseRequest, pathname })).toBe(false)
})
it('does not confuse similarly named console routes with share routes', () => {
expect(isCloudAnalyticsPath('/workflow-builder')).toBe(true)
})
it.each([
@@ -0,0 +1,48 @@
'use client'
import { useSelectedLayoutSegments } from '@/next/navigation'
import Script from '@/next/script'
import { GoogleAnalyticsTagScripts, GoogleConsentDefaults } from '../ga'
import { ConsoleAnalyticsRuntime } from './console-analytics-runtime'
import { WebAppAnalyticsRuntime } from './web-app-analytics-runtime'
const SHARE_LAYOUT_SEGMENT = '(shareLayout)'
type CloudAnalyticsLayoutBoundaryProps = {
cookieYesSiteKey: string
nonce?: string
}
export function CloudAnalyticsLayoutBoundary({
cookieYesSiteKey,
nonce,
}: CloudAnalyticsLayoutBoundaryProps) {
const layoutSegments = useSelectedLayoutSegments()
const cookieYesScript = (
<Script
id="cookieyes"
strategy="beforeInteractive"
type="text/javascript"
src={`https://cdn-cookieyes.com/client_data/${cookieYesSiteKey}/script.js`}
nonce={nonce}
/>
)
if (layoutSegments.includes(SHARE_LAYOUT_SEGMENT)) {
return (
<>
{cookieYesScript}
<WebAppAnalyticsRuntime />
</>
)
}
return (
<>
<GoogleConsentDefaults nonce={nonce} />
{cookieYesScript}
<GoogleAnalyticsTagScripts nonce={nonce} />
<ConsoleAnalyticsRuntime />
</>
)
}
@@ -1,26 +1,20 @@
import { COOKIEYES_SITE_KEY, IS_PROD, WEB_PREFIX } from '@/config'
import { getCachedSystemFeatures } from '@/features/system-features/server'
import { headers } from '@/next/headers'
import Script from '@/next/script'
import { GoogleAnalyticsTagScripts, GoogleConsentDefaults } from '../ga'
import { CloudAnalyticsRuntime } from './cloud-analytics-runtime'
import { CloudAnalyticsLayoutBoundary } from './cloud-analytics-layout-boundary'
import { isCloudAnalyticsRequest } from './request-boundary'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
export async function CloudAnalytics() {
const systemFeatures = getCachedSystemFeatures()
if (!systemFeatures) return null
const requestHeaders = await headers()
const pathname = requestHeaders.get(CURRENT_PATHNAME_HEADER) || '/'
const requestHost = requestHeaders.get('x-forwarded-host') || requestHeaders.get('host')
const enabled = isCloudAnalyticsRequest({
cookieYesSiteKey: COOKIEYES_SITE_KEY,
deploymentEdition: systemFeatures.deployment_edition,
isProd: IS_PROD,
pathname,
requestHost,
webPrefix: WEB_PREFIX,
})
@@ -28,20 +22,6 @@ export async function CloudAnalytics() {
if (!enabled) return null
const nonce = requestHeaders.get('x-nonce') ?? undefined
const cookieYesScriptSrc = `https://cdn-cookieyes.com/client_data/${COOKIEYES_SITE_KEY}/script.js`
return (
<>
<GoogleConsentDefaults nonce={nonce} />
<Script
id="cookieyes"
strategy="beforeInteractive"
type="text/javascript"
src={cookieYesScriptSrc}
nonce={nonce}
/>
<GoogleAnalyticsTagScripts nonce={nonce} />
<CloudAnalyticsRuntime />
</>
)
return <CloudAnalyticsLayoutBoundary cookieYesSiteKey={COOKIEYES_SITE_KEY} nonce={nonce} />
}
@@ -2,17 +2,13 @@
import AmplitudeProvider from '@/app/components/base/amplitude'
import ExternalAttributionRecorder from '@/app/components/external-attribution-recorder'
import { usePathname } from '@/next/navigation'
import { CookieYesConsentBridge } from './cookieyes-consent-bridge'
import { isCloudAnalyticsPath } from './request-boundary'
export function CloudAnalyticsRuntime() {
const pathname = usePathname()
export function ConsoleAnalyticsRuntime() {
return (
<>
<CookieYesConsentBridge />
<AmplitudeProvider active={isCloudAnalyticsPath(pathname)} />
<AmplitudeProvider />
<ExternalAttributionRecorder />
</>
)
@@ -1,35 +1,17 @@
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
const EXCLUDED_ANALYTICS_PATH_SEGMENTS = [
'/agent',
'/chat',
'/chatbot',
'/completion',
'/workflow',
'/webapp-reset-password',
'/webapp-signin',
] as const
type CloudAnalyticsRequest = {
cookieYesSiteKey: string | undefined
deploymentEdition: DeploymentEdition
isProd: boolean
pathname: string
requestHost: string | null
webPrefix: string | undefined
}
export function isCloudAnalyticsPath(pathname: string) {
return !EXCLUDED_ANALYTICS_PATH_SEGMENTS.some(
(segment) => pathname === segment || pathname.startsWith(`${segment}/`),
)
}
export function isCloudAnalyticsRequest({
cookieYesSiteKey,
deploymentEdition,
isProd,
pathname,
requestHost,
webPrefix,
}: CloudAnalyticsRequest) {
@@ -41,7 +23,6 @@ export function isCloudAnalyticsRequest({
!webPrefix
)
return false
if (!isCloudAnalyticsPath(pathname)) return false
try {
const expectedHost = new URL(webPrefix).host.toLowerCase()
@@ -0,0 +1,13 @@
'use client'
import { WebAppAmplitudeProvider } from '@/app/components/base/amplitude/WebAppAmplitudeProvider'
import { CookieYesConsentBridge } from './cookieyes-consent-bridge'
export function WebAppAnalyticsRuntime() {
return (
<>
<CookieYesConsentBridge />
<WebAppAmplitudeProvider />
</>
)
}
@@ -19,8 +19,8 @@ import { useChatWithHistoryContext } from '../context'
const mockTrackEvent = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mockTrackEvent,
vi.mock('@/app/components/base/amplitude/web-app-event', () => ({
trackWebAppEvent: mockTrackEvent,
}))
vi.mock('../../chat/hooks', () => ({
@@ -6,7 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { trackWebAppEvent } from '@/app/components/base/amplitude/web-app-event'
import AnswerIcon from '@/app/components/base/answer-icon'
import AppIcon from '@/app/components/base/app-icon'
import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form'
@@ -225,7 +225,7 @@ const ChatWrapper = () => {
})
const appMode = isNewAgent ? 'agent-v2' : appData?.mode
if (appSourceType === AppSourceType.webApp && appMode)
trackEvent('webapp_run', { app_mode: appMode })
trackWebAppEvent('webapp_run', { app_mode: appMode })
},
[
inputsForms,
@@ -13,8 +13,8 @@ import { useEmbeddedChatbotContext } from '../context'
const mockTrackEvent = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mockTrackEvent,
vi.mock('@/app/components/base/amplitude/web-app-event', () => ({
trackWebAppEvent: mockTrackEvent,
}))
vi.mock('../context', () => ({
@@ -6,7 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { trackWebAppEvent } from '@/app/components/base/amplitude/web-app-event'
import AnswerIcon from '@/app/components/base/answer-icon'
import AppIcon from '@/app/components/base/app-icon'
import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
@@ -210,7 +210,7 @@ const ChatWrapper = () => {
isPublicAPI: appSourceType === AppSourceType.webApp,
})
if (appSourceType === AppSourceType.webApp && appData?.mode)
trackEvent('webapp_run', { app_mode: appData.mode })
trackWebAppEvent('webapp_run', { app_mode: appData.mode })
},
[
currentConversationId,
@@ -56,7 +56,8 @@ const resolveAttributionSearchParams = (
/**
* 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=…`),
* Blog links point straight at cloud.dify.ai (e.g. `/apps?utm_source=dify_blog&slug=…`
* or `/agents?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:
@@ -33,8 +33,8 @@ const {
validateResultRequestMock: vi.fn(),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mockTrackEvent,
vi.mock('@/app/components/base/amplitude/web-app-event', () => ({
trackWebAppEvent: mockTrackEvent,
}))
vi.mock('@/context/web-app-context', () => ({
@@ -4,7 +4,7 @@ import type { ResultRunStateController } from './use-result-run-state'
import type { PromptConfig } from '@/models/debug'
import type { VisionFile, VisionSettings } from '@/types/app'
import { useCallback, useEffect, useRef } from 'react'
import { trackEvent } from '@/app/components/base/amplitude'
import { trackWebAppEvent } from '@/app/components/base/amplitude/web-app-event'
import { TEXT_GENERATION_TIMEOUT_MS } from '@/config'
import { useWebAppStore } from '@/context/web-app-context'
import { AppSourceType, sendCompletionMessage, sendWorkflowMessage } from '@/service/share'
@@ -100,7 +100,7 @@ export const useResultSender = ({
runState.setRespondingTrue()
if (appSourceType === AppSourceType.webApp && appMode)
trackEvent('webapp_run', { app_mode: appMode })
trackWebAppEvent('webapp_run', { app_mode: appMode })
let isEnd = false
let isTimeout = false
+1
View File
@@ -6,5 +6,6 @@ export {
useRouter,
useSearchParams,
useSelectedLayoutSegment,
useSelectedLayoutSegments,
} from 'next/navigation'
export type { ReadonlyURLSearchParams } from 'next/navigation'
@@ -319,6 +319,30 @@ describe('create-app-tracking', () => {
})
})
it('should attribute an agent roster creation to the external agents landing URL', () => {
window.history.replaceState(
{},
'',
'/agents?utm_source=dify_blog&slug=buildaisupportassistantwithdify',
)
rememberCreateAppExternalAttribution({
searchParams: new URLSearchParams(window.location.search),
})
trackCreateApp({
source: 'studio_blank',
appMode: 'agent-v2',
})
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
source: 'external',
app_mode: 'agent-v2',
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
utm_source: 'dify_blog',
slug: 'buildaisupportassistantwithdify',
})
})
it('should keep using remembered external attribution after navigating away from the original url', () => {
window.history.replaceState({}, '', '/apps?utm_source=linkedin&slug=agent-launch')