mirror of
https://github.com/langgenius/dify.git
synced 2026-07-26 06:08:38 +08:00
feat: gate cloud analytics behind cookie consent (#39408)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
parent
6736f4005b
commit
8d293023cc
@ -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=
|
||||
|
||||
@ -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:-}
|
||||
|
||||
@ -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:-}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
17
web/app/components/base/analytics-consent/README.md
Normal file
17
web/app/components/base/analytics-consent/README.md
Normal 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`
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
83
web/app/components/base/analytics-consent/consent-store.ts
Normal file
83
web/app/components/base/analytics-consent/consent-store.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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', '')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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).
|
||||
|
||||
10
web/env.ts
10
web/env.ts
@ -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'),
|
||||
|
||||
@ -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']
|
||||
|
||||
Loading…
Reference in New Issue
Block a user