mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix: separate WebApp analytics from console page tracking (#41251)
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
+100
@@ -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
-6
@@ -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:
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in new issue
Block a user