From 8d293023ccb8480226c94948766c145c563bfa2c Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 23 Jul 2026 10:40:54 +0800 Subject: [PATCH] feat: gate cloud analytics behind cookie consent (#39408) Co-authored-by: CodingOnStar --- docker/.env.example | 1 + docker/docker-compose-template.yaml | 1 + docker/docker-compose.yaml | 1 + docker/envs/core-services/web.env.example | 1 + web/.env.example | 6 + .../(commonLayout)/external-service-sync.tsx | 13 +- web/app/(commonLayout)/providers.tsx | 4 - .../external-attribution-recorder.spec.tsx | 37 +++- .../base/amplitude/AmplitudeProvider.tsx | 27 ++- .../__tests__/AmplitudeProvider.spec.tsx | 52 +++++- .../base/amplitude/__tests__/init.spec.ts | 35 ++++ .../__tests__/registration-tracking.spec.ts | 29 +++ .../base/amplitude/__tests__/utils.spec.ts | 60 ++++++ web/app/components/base/amplitude/init.ts | 18 ++ .../amplitude/lazy-amplitude-provider.tsx | 10 +- .../base/amplitude/registration-tracking.ts | 12 +- .../amplitude/use-amplitude-initialized.ts | 14 ++ web/app/components/base/amplitude/utils.ts | 15 +- .../base/analytics-consent/README.md | 17 ++ .../cloud-analytics-boundary.spec.tsx | 146 +++++++++++++++ .../cloud-analytics-runtime.spec.tsx | 45 +++++ .../__tests__/consent-store.spec.ts | 52 ++++++ .../cookieyes-consent-bridge.spec.tsx | 47 +++++ .../__tests__/request-boundary.spec.ts | 55 ++++++ .../cloud-analytics-boundary.tsx | 27 +++ .../cloud-analytics-runtime.tsx | 17 ++ .../cloud-analytics-state.ts | 35 ++++ .../base/analytics-consent/consent-store.ts | 83 +++++++++ .../cookieyes-consent-bridge.tsx | 30 +++ .../analytics-consent/request-boundary.ts | 45 +++++ .../base/ga/__tests__/index.spec.tsx | 172 +++++------------- web/app/components/base/ga/index.tsx | 40 ++-- .../external-attribution-recorder.tsx | 6 +- web/app/layout.tsx | 9 +- web/config/index.ts | 2 + .../__tests__/console-bootstrap.spec.tsx | 25 ++- web/docker/entrypoint.sh | 2 + web/env.ts | 10 + web/proxy.ts | 2 +- 39 files changed, 1022 insertions(+), 181 deletions(-) create mode 100644 web/app/components/base/amplitude/use-amplitude-initialized.ts create mode 100644 web/app/components/base/analytics-consent/README.md create mode 100644 web/app/components/base/analytics-consent/__tests__/cloud-analytics-boundary.spec.tsx create mode 100644 web/app/components/base/analytics-consent/__tests__/cloud-analytics-runtime.spec.tsx create mode 100644 web/app/components/base/analytics-consent/__tests__/consent-store.spec.ts create mode 100644 web/app/components/base/analytics-consent/__tests__/cookieyes-consent-bridge.spec.tsx create mode 100644 web/app/components/base/analytics-consent/__tests__/request-boundary.spec.ts create mode 100644 web/app/components/base/analytics-consent/cloud-analytics-boundary.tsx create mode 100644 web/app/components/base/analytics-consent/cloud-analytics-runtime.tsx create mode 100644 web/app/components/base/analytics-consent/cloud-analytics-state.ts create mode 100644 web/app/components/base/analytics-consent/consent-store.ts create mode 100644 web/app/components/base/analytics-consent/cookieyes-consent-bridge.tsx create mode 100644 web/app/components/base/analytics-consent/request-boundary.ts 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 + }) => ( + + ) +} +export function GoogleAnalyticsTagScripts({ nonce }: AnalyticsScriptProps) { return ( <> - -