feat: gate cloud analytics behind cookie consent (#39408)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star 2026-07-23 10:40:54 +08:00 committed by GitHub
parent 6736f4005b
commit 8d293023cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1022 additions and 181 deletions

View File

@ -140,6 +140,7 @@ API_SENTRY_TRACES_SAMPLE_RATE=1.0
API_SENTRY_PROFILES_SAMPLE_RATE=1.0
WEB_SENTRY_DSN=
AMPLITUDE_API_KEY=
COOKIEYES_SITE_KEY=
TEXT_GENERATION_TIMEOUT_MS=60000
WORKFLOW_GENERATION_TIMEOUT_MS=180000
CSP_WHITELIST=

View File

@ -391,6 +391,7 @@ services:
SERVER_CONSOLE_API_URL: ${SERVER_CONSOLE_API_URL:-http://api:5001}
APP_API_URL: ${APP_API_URL:-}
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
COOKIEYES_SITE_KEY: ${COOKIEYES_SITE_KEY:-}
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
NEXT_PUBLIC_SOCKET_URL: ${NEXT_PUBLIC_SOCKET_URL:-ws://localhost}
SENTRY_DSN: ${WEB_SENTRY_DSN:-}

View File

@ -397,6 +397,7 @@ services:
SERVER_CONSOLE_API_URL: ${SERVER_CONSOLE_API_URL:-http://api:5001}
APP_API_URL: ${APP_API_URL:-}
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
COOKIEYES_SITE_KEY: ${COOKIEYES_SITE_KEY:-}
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
NEXT_PUBLIC_SOCKET_URL: ${NEXT_PUBLIC_SOCKET_URL:-ws://localhost}
SENTRY_DSN: ${WEB_SENTRY_DSN:-}

View File

@ -20,6 +20,7 @@ MARKETPLACE_API_URL=https://marketplace.dify.ai
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
ALLOW_EMBED=false
AMPLITUDE_API_KEY=
COOKIEYES_SITE_KEY=
ENABLE_WEBSITE_JINAREADER=true
ENABLE_WEBSITE_FIRECRAWL=true
ENABLE_WEBSITE_WATERCRAWL=true

View File

@ -104,6 +104,12 @@ NEXT_PUBLIC_MAX_TREE_DEPTH=50
# The API key of amplitude
NEXT_PUBLIC_AMPLITUDE_API_KEY=
# CookieYes site key for the Dify Cloud consent banner
NEXT_PUBLIC_COOKIEYES_SITE_KEY=
# The public origin of the console web application
NEXT_PUBLIC_WEB_PREFIX=
# number of concurrency
NEXT_PUBLIC_BATCH_CONCURRENCY=5

View File

@ -1,12 +1,21 @@
'use client'
import { useAtomValue } from 'jotai'
import { useAmplitudeInitialized } from '@/app/components/base/amplitude/use-amplitude-initialized'
import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
import { amplitudeIdentitySyncAtom } from '@/context/amplitude-identity-sync'
import { zendeskConversationSyncAtom } from '@/context/zendesk-conversation-sync'
export function ExternalServiceSync() {
useAtomValue(zendeskConversationSyncAtom)
function AmplitudeIdentitySync() {
useAtomValue(amplitudeIdentitySyncAtom)
return null
}
export function ExternalServiceSync() {
const analyticsConsent = useAnalyticsConsent()
const amplitudeInitialized = useAmplitudeInitialized()
useAtomValue(zendeskConversationSyncAtom)
return analyticsConsent === 'granted' && amplitudeInitialized ? <AmplitudeIdentitySync /> : null
}

View File

@ -1,6 +1,4 @@
import type { ReactNode } from 'react'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@ -13,8 +11,6 @@ import { CommonLayoutHydrationBoundary } from './hydration-boundary'
export async function ConsoleRuntimeProviders({ children }: { children: ReactNode }) {
return (
<>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>

View File

@ -1,6 +1,8 @@
import { render, waitFor } from '@testing-library/react'
import type { AnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
import { act, render, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
import { useSearchParams } from '@/next/navigation'
import ExternalAttributionRecorder from '../external-attribution-recorder'
@ -42,9 +44,42 @@ describe('ExternalAttributionRecorder', () => {
vi.clearAllMocks()
Cookies.remove('utm_info')
mockConfig.IS_CLOUD_EDITION = true
setAnalyticsConsent('granted')
setSearchParams()
})
it.each<AnalyticsConsent>(['unknown', 'denied'])(
'does not persist attribution when analytics consent is %s',
(consent) => {
setAnalyticsConsent(consent)
setSearchParams('utm_source=dify_blog&slug=get-started-with-dify')
render(<ExternalAttributionRecorder />)
expect(getUtmInfoCookie()).toBeNull()
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
},
)
it('persists attribution when analytics consent becomes granted', async () => {
setAnalyticsConsent('unknown')
setSearchParams('utm_source=dify_blog&slug=get-started-with-dify')
render(<ExternalAttributionRecorder />)
expect(getUtmInfoCookie()).toBeNull()
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
act(() => setAnalyticsConsent('granted'))
await waitFor(() => {
expect(getUtmInfoCookie()).toEqual({
utm_source: 'dify_blog',
slug: 'get-started-with-dify',
})
})
expect(mockRememberCreateAppExternalAttribution).toHaveBeenCalledTimes(1)
})
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')

View File

@ -1,22 +1,31 @@
'use client'
import type { FC } from 'react'
import type { AmplitudeInitializationOptions } from './init'
import * as React from 'react'
import { useEffect } from 'react'
import { ensureAmplitudeInitialized } from './init'
import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
import { ensureAmplitudeInitialized, setAmplitudeOptOut } from './init'
export type IAmplitudeProps = AmplitudeInitializationOptions
export type IAmplitudeProps = AmplitudeInitializationOptions & {
active?: boolean
}
export function AmplitudeProvider({
active = true,
sessionReplaySampleRate = 0.5,
}: IAmplitudeProps) {
const consent = useAnalyticsConsent()
const AmplitudeProvider: FC<IAmplitudeProps> = ({ sessionReplaySampleRate = 0.5 }) => {
useEffect(() => {
if (!active || consent !== 'granted') {
setAmplitudeOptOut(true)
return
}
ensureAmplitudeInitialized({
sessionReplaySampleRate,
})
}, [sessionReplaySampleRate])
setAmplitudeOptOut(false)
}, [active, consent, sessionReplaySampleRate])
// This is a client component that renders nothing
return null
}
export default React.memo(AmplitudeProvider)

View File

@ -7,8 +7,11 @@ const mockConfig = vi.hoisted(() => ({
AMPLITUDE_API_KEY: 'test-api-key',
IS_CLOUD_EDITION: true,
}))
const mockConsent = vi.hoisted(() => ({
value: 'granted' as 'unknown' | 'denied' | 'granted',
}))
let AmplitudeProvider: typeof import('../AmplitudeProvider').default
let AmplitudeProvider: typeof import('../AmplitudeProvider').AmplitudeProvider
vi.mock('@/config', () => ({
get AMPLITUDE_API_KEY() {
@ -25,19 +28,25 @@ vi.mock('@/config', () => ({
vi.mock('@amplitude/analytics-browser', () => ({
init: vi.fn(),
add: vi.fn(),
setOptOut: vi.fn(),
}))
vi.mock('@amplitude/plugin-session-replay-browser', () => ({
sessionReplayPlugin: vi.fn(() => ({ name: 'session-replay' })),
}))
vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
useAnalyticsConsent: () => mockConsent.value,
}))
describe('AmplitudeProvider', () => {
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
mockConfig.IS_CLOUD_EDITION = true
;({ default: AmplitudeProvider } = await import('../AmplitudeProvider'))
mockConsent.value = 'granted'
;({ AmplitudeProvider } = await import('../AmplitudeProvider'))
})
describe('Component', () => {
@ -47,6 +56,7 @@ describe('AmplitudeProvider', () => {
expect(amplitude.init).toHaveBeenCalledWith('test-api-key', expect.any(Object))
expect(sessionReplayPlugin).toHaveBeenCalledWith({ sampleRate: 0.8 })
expect(amplitude.add).toHaveBeenCalledTimes(2)
expect(amplitude.setOptOut).toHaveBeenCalledWith(false)
})
it('does not re-initialize amplitude on remount', () => {
@ -68,6 +78,44 @@ describe('AmplitudeProvider', () => {
expect(amplitude.add).not.toHaveBeenCalled()
})
it.each(['unknown', 'denied'] as const)(
'does not initialize amplitude while consent is %s',
(consent) => {
mockConsent.value = consent
render(<AmplitudeProvider />)
expect(amplitude.init).not.toHaveBeenCalled()
expect(amplitude.add).not.toHaveBeenCalled()
expect(amplitude.setOptOut).not.toHaveBeenCalled()
},
)
it('opts out on revoke and resumes without reinitializing', () => {
const { rerender } = render(<AmplitudeProvider />)
mockConsent.value = 'denied'
rerender(<AmplitudeProvider />)
expect(amplitude.setOptOut).toHaveBeenLastCalledWith(true)
mockConsent.value = 'granted'
rerender(<AmplitudeProvider />)
expect(amplitude.setOptOut).toHaveBeenLastCalledWith(false)
expect(amplitude.init).toHaveBeenCalledTimes(1)
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
expect(amplitude.add).toHaveBeenCalledTimes(2)
})
it('opts out when the runtime leaves the first-party route boundary', () => {
const { rerender } = render(<AmplitudeProvider active />)
rerender(<AmplitudeProvider active={false} />)
expect(amplitude.setOptOut).toHaveBeenLastCalledWith(true)
})
it('pageNameEnrichmentPlugin logic works as expected', async () => {
render(<AmplitudeProvider />)
const plugin = vi.mocked(amplitude.add).mock.calls[0]?.[0] as

View File

@ -24,6 +24,7 @@ vi.mock('@/config', () => ({
vi.mock('@amplitude/analytics-browser', () => ({
init: vi.fn(),
add: vi.fn(),
setOptOut: vi.fn(),
}))
vi.mock('@amplitude/plugin-session-replay-browser', () => ({
@ -50,6 +51,26 @@ describe('amplitude init helper', () => {
expect(amplitude.add).toHaveBeenCalledTimes(2)
})
it('should expose readiness after initialization completes', async () => {
const { getIsAmplitudeInitialized } = await import('../init')
expect(getIsAmplitudeInitialized()).toBe(false)
ensureAmplitudeInitialized()
expect(getIsAmplitudeInitialized()).toBe(true)
})
it('should notify readiness subscribers after plugins are registered', async () => {
const { subscribeAmplitudeInitialization } = await import('../init')
const listener = vi.fn()
const unsubscribe = subscribeAmplitudeInitialization(listener)
ensureAmplitudeInitialized()
expect(listener).toHaveBeenCalledTimes(1)
unsubscribe()
})
it('should skip initialization when amplitude is disabled', () => {
mockConfig.AMPLITUDE_API_KEY = ''
@ -60,4 +81,18 @@ describe('amplitude init helper', () => {
expect(amplitude.add).not.toHaveBeenCalled()
})
})
describe('setAmplitudeOptOut', () => {
it('only updates opt-out after amplitude has initialized', async () => {
const { setAmplitudeOptOut } = await import('../init')
setAmplitudeOptOut(true)
expect(amplitude.setOptOut).not.toHaveBeenCalled()
ensureAmplitudeInitialized()
setAmplitudeOptOut(true)
expect(amplitude.setOptOut).toHaveBeenCalledWith(true)
})
})
})

View File

@ -5,16 +5,24 @@ import {
} from '../registration-tracking'
const mockTrackEvent = vi.hoisted(() => vi.fn())
const mockConsent = vi.hoisted(() => ({
value: 'granted' as 'unknown' | 'denied' | 'granted',
}))
vi.mock('../utils', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
getAnalyticsConsent: () => mockConsent.value,
}))
describe('registration tracking', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
window.sessionStorage.clear()
mockConsent.value = 'granted'
})
// Captures the registration event for a later flush instead of firing it right away.
@ -58,6 +66,17 @@ describe('registration tracking', () => {
vi.unstubAllGlobals()
}
})
it.each(['unknown', 'denied'] as const)(
'should not cache an event while consent is %s',
(consent) => {
mockConsent.value = consent
rememberRegistrationSuccess({ method: 'email' })
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
},
)
})
// Replays the remembered event exactly once, after the user ID has been attached.
@ -90,6 +109,16 @@ describe('registration tracking', () => {
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
})
it('should discard a pending event when consent was revoked before flush', () => {
rememberRegistrationSuccess({ method: 'oauth' })
mockConsent.value = 'denied'
flushRegistrationSuccess()
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
})
it('should clear malformed pending data without tracking', () => {
window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, '{not-json')

View File

@ -1,7 +1,9 @@
import { flushEvents, resetUser, setUserId, setUserProperties, trackEvent } from '../utils'
const mockState = vi.hoisted(() => ({
consent: 'granted' as 'unknown' | 'denied' | 'granted',
enabled: true,
initialized: true,
}))
const mockTrack = vi.hoisted(() => vi.fn())
@ -28,6 +30,14 @@ vi.mock('@/config', () => ({
},
}))
vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
getAnalyticsConsent: () => mockState.consent,
}))
vi.mock('../init', () => ({
getIsAmplitudeInitialized: () => mockState.initialized,
}))
vi.mock('@amplitude/analytics-browser', () => ({
track: (...args: unknown[]) => mockTrack(...args),
flush: (...args: unknown[]) => mockFlush(...args),
@ -40,7 +50,9 @@ vi.mock('@amplitude/analytics-browser', () => ({
describe('amplitude utils', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.consent = 'granted'
mockState.enabled = true
mockState.initialized = true
})
describe('trackEvent', () => {
@ -62,6 +74,22 @@ describe('amplitude utils', () => {
expect(mockTrack).not.toHaveBeenCalled()
})
it.each(['unknown', 'denied'] as const)('should drop events while consent is %s', (consent) => {
mockState.consent = consent
trackEvent('dataset_created', { source: 'wizard' })
expect(mockTrack).not.toHaveBeenCalled()
})
it('should drop events until the consented SDK has initialized', () => {
mockState.initialized = false
trackEvent('dataset_created')
expect(mockTrack).not.toHaveBeenCalled()
})
})
describe('flushEvents', () => {
@ -82,6 +110,14 @@ describe('amplitude utils', () => {
expect(mockFlush).not.toHaveBeenCalled()
})
it('should not flush when analytics consent is denied', () => {
mockState.consent = 'denied'
flushEvents()
expect(mockFlush).not.toHaveBeenCalled()
})
})
describe('setUserId', () => {
@ -99,6 +135,14 @@ describe('amplitude utils', () => {
expect(mockSetUserId).not.toHaveBeenCalled()
})
it('should not set user id when analytics consent is denied', () => {
mockState.consent = 'denied'
setUserId('user-123')
expect(mockSetUserId).not.toHaveBeenCalled()
})
})
describe('setUserProperties', () => {
@ -128,6 +172,14 @@ describe('amplitude utils', () => {
expect(mockIdentify).not.toHaveBeenCalled()
})
it('should not identify when analytics consent is denied', () => {
mockState.consent = 'denied'
setUserProperties({ role: 'owner' })
expect(mockIdentify).not.toHaveBeenCalled()
})
})
describe('resetUser', () => {
@ -144,5 +196,13 @@ describe('amplitude utils', () => {
expect(mockReset).not.toHaveBeenCalled()
})
it('should not reset when analytics consent is denied', () => {
mockState.consent = 'denied'
resetUser()
expect(mockReset).not.toHaveBeenCalled()
})
})
})

View File

@ -7,6 +7,18 @@ export type AmplitudeInitializationOptions = {
}
let isAmplitudeInitialized = false
const initializationListeners = new Set<() => void>()
export const getIsAmplitudeInitialized = () => isAmplitudeInitialized
export const subscribeAmplitudeInitialization = (listener: () => void) => {
initializationListeners.add(listener)
return () => initializationListeners.delete(listener)
}
const notifyAmplitudeInitialized = () => {
initializationListeners.forEach((listener) => listener())
}
// Map URL pathname to English page name for consistent Amplitude tracking
const getEnglishPageName = (pathname: string): string => {
@ -70,8 +82,14 @@ export const ensureAmplitudeInitialized = ({
sampleRate: sessionReplaySampleRate,
}),
)
notifyAmplitudeInitialized()
} catch (error) {
isAmplitudeInitialized = false
throw error
}
}
export const setAmplitudeOptOut = (optOut: boolean) => {
if (!isAmplitudeEnabled || !isAmplitudeInitialized) return
amplitude.setOptOut(optOut)
}

View File

@ -1,11 +1,15 @@
'use client'
import type { FC } from 'react'
import type { IAmplitudeProps } from './AmplitudeProvider'
import dynamic from '@/next/dynamic'
const AmplitudeProvider = dynamic(() => import('./AmplitudeProvider'), { ssr: false })
const AmplitudeProvider = dynamic(
() => import('./AmplitudeProvider').then((module) => module.AmplitudeProvider),
{ ssr: false },
)
const LazyAmplitudeProvider: FC<IAmplitudeProps> = (props) => <AmplitudeProvider {...props} />
function LazyAmplitudeProvider(props: IAmplitudeProps) {
return <AmplitudeProvider {...props} />
}
export default LazyAmplitudeProvider

View File

@ -1,3 +1,4 @@
import { getAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
import { trackEvent } from './utils'
/**
@ -23,13 +24,14 @@ const getSessionStorage = (): Storage | null => {
}
/**
* Remember a registration success event so it can be sent to Amplitude *after* the
* user ID is attached (see `flushRegistrationSuccess`).
* Remember a registration success event after analytics consent so it can be sent
* to Amplitude *after* the user ID is attached (see `flushRegistrationSuccess`).
*
* Amplitude attributes events to whatever identity is active when `track` runs. At
* registration time the client does not yet know the user ID, so firing the event
* immediately records it under an anonymous profile. We persist the event here and
* replay it once `setUserId` runs in the bootstrap effects after the redirect.
* replay it once `setUserId` runs in the bootstrap effects after the redirect. An
* event produced before analytics consent is granted is dropped instead of queued.
*/
export const rememberRegistrationSuccess = ({
method,
@ -38,6 +40,8 @@ export const rememberRegistrationSuccess = ({
method: RegistrationMethod
utmInfo?: Record<string, unknown> | null
}) => {
if (getAnalyticsConsent() !== 'granted') return
const storage = getSessionStorage()
if (!storage) return
@ -75,6 +79,8 @@ export const flushRegistrationSuccess = () => {
storage.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY)
} catch {}
if (getAnalyticsConsent() !== 'granted') return
try {
const pending = JSON.parse(raw) as PendingRegistrationSuccessEvent
if (pending?.eventName) trackEvent(pending.eventName, pending.properties)

View File

@ -0,0 +1,14 @@
'use client'
import { useSyncExternalStore } from 'react'
import { getIsAmplitudeInitialized, subscribeAmplitudeInitialization } from './init'
const getServerAmplitudeInitialized = () => false
export function useAmplitudeInitialized() {
return useSyncExternalStore(
subscribeAmplitudeInitialization,
getIsAmplitudeInitialized,
getServerAmplitudeInitialized,
)
}

View File

@ -1,5 +1,10 @@
import * as amplitude from '@amplitude/analytics-browser'
import { getAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
import { isAmplitudeEnabled } from '@/config'
import { getIsAmplitudeInitialized } from './init'
const canUseAmplitude = () =>
isAmplitudeEnabled && getAnalyticsConsent() === 'granted' && getIsAmplitudeInitialized()
/**
* Track custom event
@ -7,12 +12,12 @@ import { isAmplitudeEnabled } from '@/config'
* @param eventProperties Event properties (optional)
*/
export const trackEvent = (eventName: string, eventProperties?: Record<string, unknown>) => {
if (!isAmplitudeEnabled) return
if (!canUseAmplitude()) return
return amplitude.track(eventName, eventProperties)
}
export const flushEvents = () => {
if (!isAmplitudeEnabled) return
if (!canUseAmplitude()) return
return amplitude.flush()
}
@ -21,7 +26,7 @@ export const flushEvents = () => {
* @param userId User ID
*/
export const setUserId = (userId: string) => {
if (!isAmplitudeEnabled) return
if (!canUseAmplitude()) return
amplitude.setUserId(userId)
}
@ -32,7 +37,7 @@ export const setUserId = (userId: string) => {
export const setUserProperties = (
properties: Record<string, amplitude.Types.ValidPropertyType>,
) => {
if (!isAmplitudeEnabled) return
if (!canUseAmplitude()) return
const identifyEvent = new amplitude.Identify()
Object.entries(properties).forEach(([key, value]) => {
identifyEvent.set(key, value)
@ -44,6 +49,6 @@ export const setUserProperties = (
* Reset user (e.g., when user logs out)
*/
export const resetUser = () => {
if (!isAmplitudeEnabled) return
if (!canUseAmplitude()) return
amplitude.reset()
}

View File

@ -0,0 +1,17 @@
# Analytics Consent
Owns CookieYes consent state and the Dify Cloud analytics boundary.
## Internal Modules
- `consent-store`
- `cookieyes-consent-bridge`
- `cloud-analytics-boundary`
- `cloud-analytics-runtime`
- `cloud-analytics-state`
- `request-boundary`
## External Modules
- `app/components/base/amplitude`
- `app/components/base/ga`

View File

@ -0,0 +1,146 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
type ConfigState = {
cookieYesSiteKey: string
isCloudEdition: boolean
isProd: boolean
webPrefix: string | undefined
}
const { configState, mockHeadersGet } = vi.hoisted(() => ({
configState: {
cookieYesSiteKey: 'site-key',
isCloudEdition: true,
isProd: true,
webPrefix: 'https://cloud.dify.ai',
} as ConfigState,
mockHeadersGet: vi.fn(),
}))
vi.mock('@/config', () => ({
get COOKIEYES_SITE_KEY() {
return configState.cookieYesSiteKey
},
get IS_CLOUD_EDITION() {
return configState.isCloudEdition
},
get IS_PROD() {
return configState.isProd
},
get WEB_PREFIX() {
return configState.webPrefix
},
}))
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 ?? ''}
/>
),
}))
async function renderBoundary() {
const [{ CloudAnalyticsBoundary }, { getCloudAnalyticsBoundaryState }] = await Promise.all([
import('../cloud-analytics-boundary'),
import('../cloud-analytics-state'),
])
const state = getCloudAnalyticsBoundaryState({ get: mockHeadersGet })
const view = render(<CloudAnalyticsBoundary {...state} />)
return { ...view, state }
}
describe('CloudAnalyticsBoundary', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
configState.cookieYesSiteKey = 'site-key'
configState.isCloudEdition = true
configState.isProd = true
configState.webPrefix = 'https://cloud.dify.ai'
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 a directly detectable CookieYes script in the head sequence', async () => {
const { container, state } = await renderBoundary()
const scripts = Array.from(container.querySelectorAll('script'))
const scriptIds = scripts.map(
(script) => script.getAttribute('id') || script.getAttribute('data-id'),
)
expect(state.enabled).toBe(true)
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')
})
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, state } = await renderBoundary()
expect(state.enabled).toBe(false)
expect(container.querySelector('script')).toBeNull()
})
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, state } = await renderBoundary()
expect(state.enabled).toBe(false)
expect(container.querySelector('script')).toBeNull()
})
})

View File

@ -0,0 +1,45 @@
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" />
),
}))
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('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')
})
})

View File

@ -0,0 +1,52 @@
import {
getAnalyticsConsent,
getAnalyticsConsentFromCookie,
getAnalyticsConsentFromEvent,
setAnalyticsConsent,
} from '../consent-store'
describe('analytics consent store', () => {
beforeEach(() => {
setAnalyticsConsent('unknown')
})
describe('CookieYes cookie parsing', () => {
it.each([
['', 'unknown'],
['session=value', 'unknown'],
['cookieyes-consent=consentid:abc,necessary:yes', 'unknown'],
['cookieyes-consent=consentid:abc,analytics:yes', 'granted'],
['cookieyes-consent=consentid:abc,analytics:no', 'denied'],
['cookieyes-consent=consentid%3Aabc%2Canalytics%3Ayes', 'granted'],
] as const)('maps %s to %s', (cookie, expected) => {
expect(getAnalyticsConsentFromCookie(cookie)).toBe(expected)
})
})
describe('CookieYes update events', () => {
it('grants consent when analytics is accepted', () => {
expect(
getAnalyticsConsentFromEvent({ accepted: ['necessary', 'analytics'], rejected: [] }),
).toBe('granted')
})
it('denies consent when analytics is rejected', () => {
expect(
getAnalyticsConsentFromEvent({ accepted: ['necessary'], rejected: ['analytics'] }),
).toBe('denied')
})
it('does not change consent for unrelated categories or malformed details', () => {
expect(
getAnalyticsConsentFromEvent({ accepted: ['functional'], rejected: ['advertisement'] }),
).toBeNull()
expect(getAnalyticsConsentFromEvent(null)).toBeNull()
})
})
it('exposes the current consent synchronously', () => {
setAnalyticsConsent('granted')
expect(getAnalyticsConsent()).toBe('granted')
})
})

View File

@ -0,0 +1,47 @@
import { render, screen, waitFor } from '@testing-library/react'
import { setAnalyticsConsent, useAnalyticsConsent } from '../consent-store'
import { COOKIEYES_CONSENT_UPDATE_EVENT, CookieYesConsentBridge } from '../cookieyes-consent-bridge'
function ConsentProbe() {
const consent = useAnalyticsConsent()
return <span>{consent}</span>
}
describe('CookieYesConsentBridge', () => {
beforeEach(() => {
document.cookie = 'cookieyes-consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'
setAnalyticsConsent('unknown')
})
it('hydrates consent from the CookieYes cookie', async () => {
document.cookie = 'cookieyes-consent=consentid:test,analytics:yes; path=/'
render(
<>
<CookieYesConsentBridge />
<ConsentProbe />
</>,
)
expect(await screen.findByText('granted')).toBeInTheDocument()
})
it('updates consent when CookieYes reports a rejection', async () => {
document.cookie = 'cookieyes-consent=consentid:test,analytics:yes; path=/'
render(
<>
<CookieYesConsentBridge />
<ConsentProbe />
</>,
)
expect(await screen.findByText('granted')).toBeInTheDocument()
document.dispatchEvent(
new CustomEvent(COOKIEYES_CONSENT_UPDATE_EVENT, {
detail: { accepted: ['necessary'], rejected: ['analytics'] },
}),
)
await waitFor(() => expect(screen.getByText('denied')).toBeInTheDocument())
})
})

View File

@ -0,0 +1,55 @@
import { isCloudAnalyticsPath, isCloudAnalyticsRequest } from '../request-boundary'
const baseRequest = {
cookieYesSiteKey: 'site-key',
isCloudEdition: true,
isProd: true,
pathname: '/signin',
requestHost: 'cloud.dify.ai',
webPrefix: 'https://cloud.dify.ai',
}
describe('cloud analytics request boundary', () => {
it('enables first-party Cloud authentication and console routes', () => {
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([
{ cookieYesSiteKey: '', reason: 'missing CookieYes configuration' },
{ isCloudEdition: false, reason: 'self-hosted edition' },
{ isProd: false, reason: 'non-production environment' },
{ requestHost: 'udify.app', reason: 'published app host' },
{ requestHost: 'customer.example.com', reason: 'custom host' },
{ webPrefix: undefined, reason: 'missing console origin' },
])('disables analytics for $reason', ({ reason: _reason, ...override }) => {
expect(isCloudAnalyticsRequest({ ...baseRequest, ...override })).toBe(false)
})
it('uses the first forwarded host and compares hosts case-insensitively', () => {
expect(
isCloudAnalyticsRequest({
...baseRequest,
requestHost: 'CLOUD.DIFY.AI, internal-proxy:3000',
}),
).toBe(true)
})
})

View File

@ -0,0 +1,27 @@
import type { CloudAnalyticsBoundaryState } from './cloud-analytics-state'
import Script from '@/next/script'
import { GoogleAnalyticsTagScripts, GoogleConsentDefaults } from '../ga'
export function CloudAnalyticsBoundary({
cookieYesSiteKey,
enabled,
nonce,
}: CloudAnalyticsBoundaryState) {
if (!enabled) return null
const cookieYesScriptSrc = `https://cdn-cookieyes.com/client_data/${cookieYesSiteKey}/script.js`
return (
<>
<GoogleConsentDefaults nonce={nonce} />
<Script
id="cookieyes"
strategy="beforeInteractive"
type="text/javascript"
src={cookieYesScriptSrc}
nonce={nonce}
/>
<GoogleAnalyticsTagScripts nonce={nonce} />
</>
)
}

View File

@ -0,0 +1,17 @@
'use client'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { usePathname } from '@/next/navigation'
import { CookieYesConsentBridge } from './cookieyes-consent-bridge'
import { isCloudAnalyticsPath } from './request-boundary'
export function CloudAnalyticsRuntime() {
const pathname = usePathname()
return (
<>
<CookieYesConsentBridge />
<AmplitudeProvider active={isCloudAnalyticsPath(pathname)} />
</>
)
}

View File

@ -0,0 +1,35 @@
import { COOKIEYES_SITE_KEY, IS_CLOUD_EDITION, IS_PROD, WEB_PREFIX } from '@/config'
import { isCloudAnalyticsRequest } from './request-boundary'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
type RequestHeaders = {
get: (name: string) => string | null
}
export type CloudAnalyticsBoundaryState = {
cookieYesSiteKey: string
enabled: boolean
nonce?: string
}
export function getCloudAnalyticsBoundaryState(
requestHeaders: RequestHeaders,
): CloudAnalyticsBoundaryState {
const pathname = requestHeaders.get(CURRENT_PATHNAME_HEADER) || '/'
const requestHost = requestHeaders.get('x-forwarded-host') || requestHeaders.get('host')
const enabled = isCloudAnalyticsRequest({
cookieYesSiteKey: COOKIEYES_SITE_KEY,
isCloudEdition: IS_CLOUD_EDITION,
isProd: IS_PROD,
pathname,
requestHost,
webPrefix: WEB_PREFIX,
})
return {
cookieYesSiteKey: COOKIEYES_SITE_KEY,
enabled,
nonce: requestHeaders.get('x-nonce') ?? undefined,
}
}

View File

@ -0,0 +1,83 @@
'use client'
import { useSyncExternalStore } from 'react'
export type AnalyticsConsent = 'unknown' | 'denied' | 'granted'
type CookieYesConsentUpdateDetail = {
accepted: string[]
rejected: string[]
}
const COOKIEYES_CONSENT_COOKIE_NAME = 'cookieyes-consent'
let analyticsConsent: AnalyticsConsent = 'unknown'
const listeners = new Set<() => void>()
const getServerAnalyticsConsent = (): AnalyticsConsent => 'unknown'
const isCookieYesConsentUpdateDetail = (
detail: unknown,
): detail is CookieYesConsentUpdateDetail => {
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return false
const candidate = detail as Partial<CookieYesConsentUpdateDetail>
return Array.isArray(candidate.accepted) && Array.isArray(candidate.rejected)
}
export function getAnalyticsConsentFromCookie(cookieHeader: string): AnalyticsConsent {
const cookiePrefix = `${COOKIEYES_CONSENT_COOKIE_NAME}=`
const rawValue = cookieHeader
.split(';')
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(cookiePrefix))
?.slice(cookiePrefix.length)
if (!rawValue) return 'unknown'
let decodedValue: string
try {
decodedValue = decodeURIComponent(rawValue)
} catch {
return 'unknown'
}
const analyticsEntry = decodedValue
.split(',')
.map((entry) => entry.trim())
.find((entry) => entry.startsWith('analytics:'))
if (analyticsEntry === 'analytics:yes') return 'granted'
if (analyticsEntry === 'analytics:no') return 'denied'
return 'unknown'
}
export function getAnalyticsConsentFromEvent(detail: unknown): AnalyticsConsent | null {
if (!isCookieYesConsentUpdateDetail(detail)) return null
if (detail.rejected.includes('analytics')) return 'denied'
if (detail.accepted.includes('analytics')) return 'granted'
return null
}
export function getAnalyticsConsent(): AnalyticsConsent {
return analyticsConsent
}
export function setAnalyticsConsent(consent: AnalyticsConsent) {
if (analyticsConsent === consent) return
analyticsConsent = consent
listeners.forEach((listener) => listener())
}
function subscribeAnalyticsConsent(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function useAnalyticsConsent() {
return useSyncExternalStore(
subscribeAnalyticsConsent,
getAnalyticsConsent,
getServerAnalyticsConsent,
)
}

View File

@ -0,0 +1,30 @@
'use client'
import { useEffect } from 'react'
import {
getAnalyticsConsentFromCookie,
getAnalyticsConsentFromEvent,
setAnalyticsConsent,
} from './consent-store'
export const COOKIEYES_CONSENT_UPDATE_EVENT = 'cookieyes_consent_update'
export function CookieYesConsentBridge() {
useEffect(() => {
setAnalyticsConsent(getAnalyticsConsentFromCookie(document.cookie))
const handleConsentUpdate = (event: Event) => {
if (!(event instanceof CustomEvent)) return
const nextConsent = getAnalyticsConsentFromEvent(event.detail)
if (nextConsent) setAnalyticsConsent(nextConsent)
}
document.addEventListener(COOKIEYES_CONSENT_UPDATE_EVENT, handleConsentUpdate)
return () => {
document.removeEventListener(COOKIEYES_CONSENT_UPDATE_EVENT, handleConsentUpdate)
}
}, [])
return null
}

View File

@ -0,0 +1,45 @@
const EXCLUDED_ANALYTICS_PATH_SEGMENTS = [
'/agent',
'/chat',
'/chatbot',
'/completion',
'/workflow',
'/webapp-reset-password',
'/webapp-signin',
] as const
type CloudAnalyticsRequest = {
cookieYesSiteKey: string | undefined
isCloudEdition: boolean
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,
isCloudEdition,
isProd,
pathname,
requestHost,
webPrefix,
}: CloudAnalyticsRequest) {
if (!isCloudEdition || !isProd || !cookieYesSiteKey?.trim() || !requestHost || !webPrefix)
return false
if (!isCloudAnalyticsPath(pathname)) return false
try {
const expectedHost = new URL(webPrefix).host.toLowerCase()
const currentHost = requestHost.split(',')[0]?.trim().toLowerCase()
return currentHost === expectedHost
} catch {
return false
}
}

View File

@ -1,34 +1,6 @@
import type { ReactElement, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
type ConfigState = {
isCloudEdition: boolean
isProd: boolean
}
type GoogleAnalyticsScriptsRenderFn = () => Promise<ReactNode>
const { mockHeaders, mockHeadersGet, configState } = vi.hoisted(() => ({
mockHeaders: vi.fn(),
mockHeadersGet: vi.fn(),
configState: {
isCloudEdition: true,
isProd: true,
} as ConfigState,
}))
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() {
return configState.isCloudEdition
},
get IS_PROD() {
return configState.isProd
},
}))
vi.mock('@/next/headers', () => ({
headers: mockHeaders,
}))
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { GoogleAnalyticsTagScripts, GoogleConsentDefaults } from '../index'
vi.mock('@/next/script', () => ({
default: ({
@ -55,112 +27,58 @@ vi.mock('@/next/script', () => ({
),
}))
const loadComponent = async () => {
const mod = await import('../index')
return {
renderer: mod.GoogleAnalyticsScripts as GoogleAnalyticsScriptsRenderFn,
}
function AnalyticsScripts({ nonce }: { nonce?: string }) {
return (
<>
<GoogleConsentDefaults nonce={nonce} />
<GoogleAnalyticsTagScripts nonce={nonce} />
</>
)
}
const renderGoogleAnalyticsScripts = async () => {
const { renderer } = await loadComponent()
const element = await renderer()
if (!element) return { element }
describe('Google Analytics scripts', () => {
it('renders denied consent defaults before the Google Analytics scripts', () => {
const { container } = render(<AnalyticsScripts nonce="test-nonce" />)
render(element as ReactElement)
return { element }
}
const scripts = Array.from(container.querySelectorAll('script'))
expect(scripts).toHaveLength(3)
describe('GA', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
expect(scripts[0]).toHaveAttribute('data-id', 'google-consent-defaults')
expect(scripts[0]).toHaveAttribute('data-strategy', 'beforeInteractive')
expect(scripts[0]).toHaveAttribute(
'data-inline',
expect.stringContaining(`window.gtag('consent', 'default'`),
)
expect(scripts[0]).toHaveAttribute(
'data-inline',
expect.stringContaining(`analytics_storage: 'denied'`),
)
configState.isCloudEdition = true
configState.isProd = true
expect(scripts[1]).toHaveAttribute('data-id', 'google-analytics')
expect(scripts[1]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[1]).toHaveAttribute(
'data-src',
'https://www.googletagmanager.com/gtag/js?id=G-DM9497FN4V',
)
mockHeadersGet.mockImplementation((name: string) => (name === 'x-nonce' ? 'test-nonce' : null))
mockHeaders.mockResolvedValue({
get: mockHeadersGet,
expect(scripts[2]).toHaveAttribute('data-id', 'google-analytics-init')
expect(scripts[2]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[2]).toHaveAttribute(
'data-inline',
expect.stringContaining(`window.gtag('config', 'G-DM9497FN4V');`),
)
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', 'test-nonce')
})
})
describe('Rendering', () => {
it('should return null when cloud edition is disabled', async () => {
configState.isCloudEdition = false
const { element } = await renderGoogleAnalyticsScripts()
it('omits the nonce when none is provided', () => {
const { container } = render(<AnalyticsScripts />)
const scripts = Array.from(container.querySelectorAll('script'))
expect(element).toBeNull()
expect(mockHeaders).not.toHaveBeenCalled()
})
it('should return null when not in production', async () => {
configState.isProd = false
const { element } = await renderGoogleAnalyticsScripts()
expect(element).toBeNull()
expect(mockHeaders).not.toHaveBeenCalled()
})
it('should render consent, CookieYes, and Google Analytics scripts in production', async () => {
await renderGoogleAnalyticsScripts()
const scripts = screen.getAllByTestId('mock-next-script')
expect(scripts).toHaveLength(4)
expect(mockHeaders).toHaveBeenCalledTimes(1)
expect(mockHeadersGet).toHaveBeenCalledWith('x-nonce')
expect(scripts[0]).toHaveAttribute('data-id', 'google-consent-defaults')
expect(scripts[0]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[0]).toHaveAttribute(
'data-inline',
expect.stringContaining(`window.gtag('consent', 'default'`),
)
expect(scripts[0]).toHaveAttribute(
'data-inline',
expect.stringContaining(`analytics_storage: 'denied'`),
)
expect(scripts[1]).toHaveAttribute('data-id', 'cookieyes')
expect(scripts[1]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[1]).toHaveAttribute(
'data-src',
'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js',
)
expect(scripts[2]).toHaveAttribute('data-id', 'google-analytics')
expect(scripts[2]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[2]).toHaveAttribute(
'data-src',
'https://www.googletagmanager.com/gtag/js?id=G-DM9497FN4V',
)
expect(scripts[3]).toHaveAttribute('data-id', 'google-analytics-init')
expect(scripts[3]).toHaveAttribute('data-strategy', 'afterInteractive')
expect(scripts[3]).toHaveAttribute(
'data-inline',
expect.stringContaining(`window.gtag('config', 'G-DM9497FN4V');`),
)
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', 'test-nonce')
})
})
})
describe('Edge Cases', () => {
it('should omit nonce when x-nonce header is missing', async () => {
mockHeadersGet.mockReturnValue(null)
await renderGoogleAnalyticsScripts()
const scripts = screen.getAllByTestId('mock-next-script')
expect(mockHeaders).toHaveBeenCalledTimes(1)
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', '')
})
scripts.forEach((script) => {
expect(script).toHaveAttribute('data-nonce', '')
})
})
})

View File

@ -1,32 +1,32 @@
import { IS_CLOUD_EDITION, IS_PROD } from '@/config'
import { headers } from '@/next/headers'
import Script from '@/next/script'
const GOOGLE_ANALYTICS_ID = 'G-DM9497FN4V'
const GOOGLE_TAG_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ANALYTICS_ID}`
const COOKIEYES_SCRIPT_SRC =
'https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js'
export async function GoogleAnalyticsScripts() {
if (!IS_CLOUD_EDITION || !IS_PROD) return null
type AnalyticsScriptProps = {
nonce?: string
}
const nonce = (await headers()).get('x-nonce') ?? undefined
export function GoogleConsentDefaults({ nonce }: AnalyticsScriptProps) {
return (
<Script id="google-consent-defaults" strategy="beforeInteractive" nonce={nonce}>
{`
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function gtag(){window.dataLayer.push(arguments);};
window.gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
});
`}
</Script>
)
}
export function GoogleAnalyticsTagScripts({ nonce }: AnalyticsScriptProps) {
return (
<>
<Script id="google-consent-defaults" strategy="afterInteractive" nonce={nonce}>
{`
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function gtag(){window.dataLayer.push(arguments);};
window.gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
});
`}
</Script>
<Script id="cookieyes" strategy="afterInteractive" src={COOKIEYES_SCRIPT_SRC} nonce={nonce} />
<Script
id="google-analytics"
strategy="afterInteractive"

View File

@ -2,6 +2,7 @@
import Cookies from 'js-cookie'
import { useEffect } from 'react'
import { useAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
import { IS_CLOUD_EDITION } from '@/config'
import { useSearchParams } from '@/next/navigation'
import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking'
@ -69,10 +70,11 @@ const resolveAttributionSearchParams = (
* slug is intentionally NOT attached to page-view events; only these conversion events.
*/
const ExternalAttributionRecorder = () => {
const analyticsConsent = useAnalyticsConsent()
const searchParams = useSearchParams()
useEffect(() => {
if (!IS_CLOUD_EDITION) return
if (!IS_CLOUD_EDITION || analyticsConsent !== 'granted') return
const attributionSearchParams = resolveAttributionSearchParams(searchParams)
if (!attributionSearchParams) return
@ -97,7 +99,7 @@ const ExternalAttributionRecorder = () => {
expires: UTM_INFO_COOKIE_EXPIRES_DAYS,
path: '/',
})
}, [searchParams])
}, [analyticsConsent, searchParams])
return null
}

View File

@ -9,6 +9,9 @@ import { TanstackQueryInitializer } from '@/context/query-client'
import { getDatasetMap } from '@/env'
import { getLocaleOnServer } from '@/i18n-config/server'
import { headers } from '@/next/headers'
import { CloudAnalyticsBoundary } from './components/base/analytics-consent/cloud-analytics-boundary'
import { CloudAnalyticsRuntime } from './components/base/analytics-consent/cloud-analytics-runtime'
import { getCloudAnalyticsBoundaryState } from './components/base/analytics-consent/cloud-analytics-state'
import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder'
import { AgentationLoader } from './components/devtools/agentation-loader'
import { ReactScanLoader } from './components/devtools/react-scan/loader'
@ -27,7 +30,9 @@ export const viewport: Viewport = {
const LocaleLayout = async ({ children }: { children: React.ReactNode }) => {
const locale = await getLocaleOnServer()
const datasetMap = getDatasetMap()
const nonce = IS_PROD ? ((await headers()).get('x-nonce') ?? undefined) : undefined
const requestHeaders = await headers()
const nonce = IS_PROD ? (requestHeaders.get('x-nonce') ?? undefined) : undefined
const cloudAnalyticsState = getCloudAnalyticsBoundaryState(requestHeaders)
return (
<html lang={locale ?? 'en'} className="h-full" suppressHydrationWarning>
@ -44,9 +49,11 @@ const LocaleLayout = async ({ children }: { children: React.ReactNode }) => {
<meta name="msapplication-TileColor" content="#1C64F2" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<CloudAnalyticsBoundary {...cloudAnalyticsState} />
<ReactScanLoader />
</head>
<body className="h-full bg-background-body" {...datasetMap}>
{cloudAnalyticsState.enabled && <CloudAnalyticsRuntime />}
<div className="isolate h-full">
<JotaiProvider>
<ThemeProvider

View File

@ -31,6 +31,8 @@ export const IS_CE_EDITION = EDITION === 'SELF_HOSTED'
export const IS_CLOUD_EDITION = EDITION === 'CLOUD'
export const AMPLITUDE_API_KEY = getStringConfig(env.NEXT_PUBLIC_AMPLITUDE_API_KEY, '')
export const COOKIEYES_SITE_KEY = getStringConfig(env.NEXT_PUBLIC_COOKIEYES_SITE_KEY, '')
export const WEB_PREFIX = env.NEXT_PUBLIC_WEB_PREFIX
export const isAmplitudeEnabled = IS_CLOUD_EDITION && !!AMPLITUDE_API_KEY

View File

@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Provider as JotaiProvider, useAtomValue, useSetAtom } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { useHydrateAtoms } from 'jotai/react/utils'
@ -8,6 +8,7 @@ import { Suspense } from 'react'
import { ExternalServiceSync } from '@/app/(commonLayout)/external-service-sync'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import { refreshUserProfileAtom, userProfileAtom } from '../account-state'
@ -184,6 +185,10 @@ vi.mock('@/app/components/base/amplitude', () => ({
setUserProperties: vi.fn(),
}))
vi.mock('@/app/components/base/amplitude/use-amplitude-initialized', () => ({
useAmplitudeInitialized: () => true,
}))
vi.mock('@/app/components/base/amplitude/registration-tracking', () => ({
flushRegistrationSuccess: vi.fn(),
}))
@ -317,6 +322,7 @@ function renderConsoleBootstrap() {
describe('Console bootstrap', () => {
beforeEach(() => {
vi.clearAllMocks()
setAnalyticsConsent('granted')
mockPermissionKeysState.isPending = false
mockPermissionKeysState.permissionKeys = ['app.create_and_management']
mockCurrentWorkspaceQueryState.data = mockCurrentWorkspaceResponse
@ -518,5 +524,22 @@ describe('Console bootstrap', () => {
expect(setUserProperties).not.toHaveBeenCalled()
expect(flushRegistrationSuccess).not.toHaveBeenCalled()
})
it('should sync an already loaded identity after analytics consent is granted', async () => {
setAnalyticsConsent('denied')
renderConsoleBootstrap()
await screen.findByText('user:user@example.com')
expect(setUserId).not.toHaveBeenCalled()
expect(setUserProperties).not.toHaveBeenCalled()
act(() => setAnalyticsConsent('granted'))
await waitFor(() => {
expect(setUserId).toHaveBeenCalledWith('user@example.com')
expect(setUserProperties).toHaveBeenCalled()
expect(flushRegistrationSuccess).toHaveBeenCalled()
})
})
})
})

View File

@ -17,6 +17,7 @@ export NEXT_PUBLIC_EDITION=${EDITION}
export NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
export NEXT_PUBLIC_API_PREFIX=${CONSOLE_API_URL}/console/api
export NEXT_PUBLIC_PUBLIC_API_PREFIX=${APP_API_URL}/api
export NEXT_PUBLIC_WEB_PREFIX=${NEXT_PUBLIC_WEB_PREFIX:-${CONSOLE_WEB_URL}}
export NEXT_PUBLIC_MARKETPLACE_API_PREFIX=${MARKETPLACE_API_URL}/api/v1
export NEXT_PUBLIC_MARKETPLACE_URL_PREFIX=${MARKETPLACE_URL}
export NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL}
@ -27,6 +28,7 @@ export NEXT_PUBLIC_SITE_ABOUT=${SITE_ABOUT}
export NEXT_TELEMETRY_DISABLED=${NEXT_TELEMETRY_DISABLED}
export NEXT_PUBLIC_AMPLITUDE_API_KEY=${AMPLITUDE_API_KEY}
export NEXT_PUBLIC_COOKIEYES_SITE_KEY=${NEXT_PUBLIC_COOKIEYES_SITE_KEY:-${COOKIEYES_SITE_KEY}}
# Cloud system-features frontend defaults.
# These values are only used when EDITION=CLOUD (IS_CLOUD_EDITION).

View File

@ -55,6 +55,13 @@ const clientSchema = {
* When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.
*/
NEXT_PUBLIC_COOKIE_DOMAIN: z.string().optional(),
/**
* CookieYes site key for the Dify Cloud consent banner.
*/
NEXT_PUBLIC_COOKIEYES_SITE_KEY: z
.string()
.regex(/^[\w-]+$/)
.optional(),
/**
* CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
*/
@ -226,6 +233,9 @@ export const env = createEnv({
NEXT_PUBLIC_COOKIE_DOMAIN: isServer
? process.env.NEXT_PUBLIC_COOKIE_DOMAIN
: getRuntimeEnvFromBody('cookieDomain'),
NEXT_PUBLIC_COOKIEYES_SITE_KEY: isServer
? process.env.NEXT_PUBLIC_COOKIEYES_SITE_KEY
: getRuntimeEnvFromBody('cookieyesSiteKey'),
NEXT_PUBLIC_CSP_WHITELIST: isServer
? process.env.NEXT_PUBLIC_CSP_WHITELIST
: getRuntimeEnvFromBody('cspWhitelist'),

View File

@ -6,7 +6,7 @@ import { NextResponse } from 'next/server'
import { env } from '@/env'
const NECESSARY_DOMAIN =
'*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://ungh.cc https://api2.amplitude.com *.amplitude.com'
'*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://cdn-cookieyes.com https://ungh.cc https://api2.amplitude.com *.amplitude.com'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
const CURRENT_SEARCH_HEADER = 'x-dify-search'
const EMBEDDABLE_PATH_PREFIXES = ['/chat', '/workflow', '/completion', '/webapp-signin']