diff --git a/docker/.env.example b/docker/.env.example
index 2071618fba8..3b3de9cd976 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -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=
diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml
index 35d5a72ff6a..37d7185e4a6 100644
--- a/docker/docker-compose-template.yaml
+++ b/docker/docker-compose-template.yaml
@@ -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:-}
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index cadfbbb7ae7..908e979c8cf 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -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:-}
diff --git a/docker/envs/core-services/web.env.example b/docker/envs/core-services/web.env.example
index c7053350a27..576e1673bc9 100644
--- a/docker/envs/core-services/web.env.example
+++ b/docker/envs/core-services/web.env.example
@@ -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
diff --git a/web/.env.example b/web/.env.example
index 794fcdf9752..0b1c2498384 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -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
diff --git a/web/app/(commonLayout)/external-service-sync.tsx b/web/app/(commonLayout)/external-service-sync.tsx
index b0ab3f166c5..a50dc3383cf 100644
--- a/web/app/(commonLayout)/external-service-sync.tsx
+++ b/web/app/(commonLayout)/external-service-sync.tsx
@@ -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 ? : null
+}
diff --git a/web/app/(commonLayout)/providers.tsx b/web/app/(commonLayout)/providers.tsx
index 92d856ca088..abd8b5a7d4f 100644
--- a/web/app/(commonLayout)/providers.tsx
+++ b/web/app/(commonLayout)/providers.tsx
@@ -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 (
<>
-
-
diff --git a/web/app/components/__tests__/external-attribution-recorder.spec.tsx b/web/app/components/__tests__/external-attribution-recorder.spec.tsx
index 51cc0ef3a10..2b4cb3ad6c6 100644
--- a/web/app/components/__tests__/external-attribution-recorder.spec.tsx
+++ b/web/app/components/__tests__/external-attribution-recorder.spec.tsx
@@ -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(['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()
+
+ 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()
+
+ 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')
diff --git a/web/app/components/base/amplitude/AmplitudeProvider.tsx b/web/app/components/base/amplitude/AmplitudeProvider.tsx
index 927c433770f..55b181aecd9 100644
--- a/web/app/components/base/amplitude/AmplitudeProvider.tsx
+++ b/web/app/components/base/amplitude/AmplitudeProvider.tsx
@@ -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 = ({ 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)
diff --git a/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx b/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx
index 128323eef3f..c546c86d60c 100644
--- a/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx
+++ b/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx
@@ -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()
+
+ 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()
+
+ mockConsent.value = 'denied'
+ rerender()
+
+ expect(amplitude.setOptOut).toHaveBeenLastCalledWith(true)
+
+ mockConsent.value = 'granted'
+ rerender()
+
+ 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()
+
+ rerender()
+
+ expect(amplitude.setOptOut).toHaveBeenLastCalledWith(true)
+ })
+
it('pageNameEnrichmentPlugin logic works as expected', async () => {
render()
const plugin = vi.mocked(amplitude.add).mock.calls[0]?.[0] as
diff --git a/web/app/components/base/amplitude/__tests__/init.spec.ts b/web/app/components/base/amplitude/__tests__/init.spec.ts
index c4675d4e86a..cea92087653 100644
--- a/web/app/components/base/amplitude/__tests__/init.spec.ts
+++ b/web/app/components/base/amplitude/__tests__/init.spec.ts
@@ -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)
+ })
+ })
})
diff --git a/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts b/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts
index 1347cb2c16f..30c6707a702 100644
--- a/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts
+++ b/web/app/components/base/amplitude/__tests__/registration-tracking.spec.ts
@@ -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')
diff --git a/web/app/components/base/amplitude/__tests__/utils.spec.ts b/web/app/components/base/amplitude/__tests__/utils.spec.ts
index 0d071fe008d..25fe773eb05 100644
--- a/web/app/components/base/amplitude/__tests__/utils.spec.ts
+++ b/web/app/components/base/amplitude/__tests__/utils.spec.ts
@@ -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()
+ })
})
})
diff --git a/web/app/components/base/amplitude/init.ts b/web/app/components/base/amplitude/init.ts
index d88cea4416c..d3b09648a92 100644
--- a/web/app/components/base/amplitude/init.ts
+++ b/web/app/components/base/amplitude/init.ts
@@ -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)
+}
diff --git a/web/app/components/base/amplitude/lazy-amplitude-provider.tsx b/web/app/components/base/amplitude/lazy-amplitude-provider.tsx
index 8449eae0c43..4a9bec9c631 100644
--- a/web/app/components/base/amplitude/lazy-amplitude-provider.tsx
+++ b/web/app/components/base/amplitude/lazy-amplitude-provider.tsx
@@ -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 = (props) =>
+function LazyAmplitudeProvider(props: IAmplitudeProps) {
+ return
+}
export default LazyAmplitudeProvider
diff --git a/web/app/components/base/amplitude/registration-tracking.ts b/web/app/components/base/amplitude/registration-tracking.ts
index 8b9cff8baaf..5562d2173c4 100644
--- a/web/app/components/base/amplitude/registration-tracking.ts
+++ b/web/app/components/base/amplitude/registration-tracking.ts
@@ -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 | 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)
diff --git a/web/app/components/base/amplitude/use-amplitude-initialized.ts b/web/app/components/base/amplitude/use-amplitude-initialized.ts
new file mode 100644
index 00000000000..06587814d8d
--- /dev/null
+++ b/web/app/components/base/amplitude/use-amplitude-initialized.ts
@@ -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,
+ )
+}
diff --git a/web/app/components/base/amplitude/utils.ts b/web/app/components/base/amplitude/utils.ts
index c5d526d5de4..d1aa0aef54e 100644
--- a/web/app/components/base/amplitude/utils.ts
+++ b/web/app/components/base/amplitude/utils.ts
@@ -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) => {
- 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,
) => {
- 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()
}
diff --git a/web/app/components/base/analytics-consent/README.md b/web/app/components/base/analytics-consent/README.md
new file mode 100644
index 00000000000..71b799c3c08
--- /dev/null
+++ b/web/app/components/base/analytics-consent/README.md
@@ -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`
diff --git a/web/app/components/base/analytics-consent/__tests__/cloud-analytics-boundary.spec.tsx b/web/app/components/base/analytics-consent/__tests__/cloud-analytics-boundary.spec.tsx
new file mode 100644
index 00000000000..f6f7444b4fc
--- /dev/null
+++ b/web/app/components/base/analytics-consent/__tests__/cloud-analytics-boundary.spec.tsx
@@ -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
+ }) => (
+
+ ),
+}))
+
+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()
+ 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 = {
+ 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 = {
+ 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 = {
+ 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()
+ })
+})
diff --git a/web/app/components/base/analytics-consent/__tests__/cloud-analytics-runtime.spec.tsx b/web/app/components/base/analytics-consent/__tests__/cloud-analytics-runtime.spec.tsx
new file mode 100644
index 00000000000..9abb21634a8
--- /dev/null
+++ b/web/app/components/base/analytics-consent/__tests__/cloud-analytics-runtime.spec.tsx
@@ -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: () => ,
+}))
+
+vi.mock('@/app/components/base/amplitude', () => ({
+ default: ({ active }: { active: boolean }) => (
+
+ ),
+}))
+
+describe('CloudAnalyticsRuntime', () => {
+ beforeEach(() => {
+ mockState.pathname = '/signin'
+ })
+
+ it('keeps consent active across first-party Cloud routes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByTestId('cookieyes-consent-bridge')).toBeInTheDocument()
+ expect(screen.getByTestId('amplitude-provider')).toHaveAttribute('data-active', 'true')
+
+ mockState.pathname = '/integrations'
+ rerender()
+
+ expect(screen.getByTestId('amplitude-provider')).toHaveAttribute('data-active', 'true')
+ })
+
+ it('opts Amplitude out after a client transition into a share route', () => {
+ const { rerender } = render()
+
+ mockState.pathname = '/workflow/token'
+ rerender()
+
+ expect(screen.getByTestId('amplitude-provider')).toHaveAttribute('data-active', 'false')
+ })
+})
diff --git a/web/app/components/base/analytics-consent/__tests__/consent-store.spec.ts b/web/app/components/base/analytics-consent/__tests__/consent-store.spec.ts
new file mode 100644
index 00000000000..f3e94a260b2
--- /dev/null
+++ b/web/app/components/base/analytics-consent/__tests__/consent-store.spec.ts
@@ -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')
+ })
+})
diff --git a/web/app/components/base/analytics-consent/__tests__/cookieyes-consent-bridge.spec.tsx b/web/app/components/base/analytics-consent/__tests__/cookieyes-consent-bridge.spec.tsx
new file mode 100644
index 00000000000..3ab0c54a2f6
--- /dev/null
+++ b/web/app/components/base/analytics-consent/__tests__/cookieyes-consent-bridge.spec.tsx
@@ -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 {consent}
+}
+
+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(
+ <>
+
+
+ >,
+ )
+
+ 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(
+ <>
+
+
+ >,
+ )
+ 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())
+ })
+})
diff --git a/web/app/components/base/analytics-consent/__tests__/request-boundary.spec.ts b/web/app/components/base/analytics-consent/__tests__/request-boundary.spec.ts
new file mode 100644
index 00000000000..4538037ce44
--- /dev/null
+++ b/web/app/components/base/analytics-consent/__tests__/request-boundary.spec.ts
@@ -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)
+ })
+})
diff --git a/web/app/components/base/analytics-consent/cloud-analytics-boundary.tsx b/web/app/components/base/analytics-consent/cloud-analytics-boundary.tsx
new file mode 100644
index 00000000000..a2e31fb4a38
--- /dev/null
+++ b/web/app/components/base/analytics-consent/cloud-analytics-boundary.tsx
@@ -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 (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/web/app/components/base/analytics-consent/cloud-analytics-runtime.tsx b/web/app/components/base/analytics-consent/cloud-analytics-runtime.tsx
new file mode 100644
index 00000000000..24fb16fc38e
--- /dev/null
+++ b/web/app/components/base/analytics-consent/cloud-analytics-runtime.tsx
@@ -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 (
+ <>
+
+
+ >
+ )
+}
diff --git a/web/app/components/base/analytics-consent/cloud-analytics-state.ts b/web/app/components/base/analytics-consent/cloud-analytics-state.ts
new file mode 100644
index 00000000000..8f3d1420437
--- /dev/null
+++ b/web/app/components/base/analytics-consent/cloud-analytics-state.ts
@@ -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,
+ }
+}
diff --git a/web/app/components/base/analytics-consent/consent-store.ts b/web/app/components/base/analytics-consent/consent-store.ts
new file mode 100644
index 00000000000..030d3f770b7
--- /dev/null
+++ b/web/app/components/base/analytics-consent/consent-store.ts
@@ -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
+ 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,
+ )
+}
diff --git a/web/app/components/base/analytics-consent/cookieyes-consent-bridge.tsx b/web/app/components/base/analytics-consent/cookieyes-consent-bridge.tsx
new file mode 100644
index 00000000000..c7a424808cd
--- /dev/null
+++ b/web/app/components/base/analytics-consent/cookieyes-consent-bridge.tsx
@@ -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
+}
diff --git a/web/app/components/base/analytics-consent/request-boundary.ts b/web/app/components/base/analytics-consent/request-boundary.ts
new file mode 100644
index 00000000000..34662428bbb
--- /dev/null
+++ b/web/app/components/base/analytics-consent/request-boundary.ts
@@ -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
+ }
+}
diff --git a/web/app/components/base/ga/__tests__/index.spec.tsx b/web/app/components/base/ga/__tests__/index.spec.tsx
index 59d5b007631..c382308d061 100644
--- a/web/app/components/base/ga/__tests__/index.spec.tsx
+++ b/web/app/components/base/ga/__tests__/index.spec.tsx
@@ -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
-
-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 (
+ <>
+
+
+ >
+ )
}
-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()
- 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()
+ 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', '')
})
})
})
diff --git a/web/app/components/base/ga/index.tsx b/web/app/components/base/ga/index.tsx
index 81f77982e45..64c99e4450a 100644
--- a/web/app/components/base/ga/index.tsx
+++ b/web/app/components/base/ga/index.tsx
@@ -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 (
+
+ )
+}
+export function GoogleAnalyticsTagScripts({ nonce }: AnalyticsScriptProps) {
return (
<>
-
-