feat: integrate Google Analytics event tracking and update CSP for script sources (#30365)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
This commit is contained in:
Coding On Star 2025-12-30 18:06:47 +08:00 committed by GitHub
parent bf76f10653
commit 6ca44eea28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 125 additions and 24 deletions

View File

@ -1,14 +1,18 @@
'use client'
import type { ReactNode } from 'react'
import Cookies from 'js-cookie'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { fetchSetupStatus } from '@/service/common'
import { sendGAEvent } from '@/utils/gtag'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
type AppInitializerProps = {
children: ReactNode
@ -22,6 +26,10 @@ export const AppInitializer = ({
// Tokens are now stored in cookies, no need to check localStorage
const pathname = usePathname()
const [init, setInit] = useState(false)
const [oauthNewUser, setOauthNewUser] = useQueryState(
'oauth_new_user',
parseAsString.withOptions({ history: 'replace' }),
)
const isSetupFinished = useCallback(async () => {
try {
@ -45,6 +53,34 @@ export const AppInitializer = ({
(async () => {
const action = searchParams.get('action')
if (oauthNewUser === 'true') {
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
utmInfo = JSON.parse(utmInfoStr)
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
}
}
// Track registration event with UTM params
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
// Clean up: remove utm_info cookie and URL params
Cookies.remove('utm_info')
setOauthNewUser(null)
}
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
@ -67,7 +103,7 @@ export const AppInitializer = ({
router.replace('/signin')
}
})()
}, [isSetupFinished, router, pathname, searchParams])
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser, setOauthNewUser])
return init ? children : null
}

View File

@ -68,6 +68,7 @@ const AmplitudeProvider: FC<IAmplitudeProps> = ({
pageViews: true,
formInteractions: true,
fileDownloads: true,
attribution: true,
},
})

View File

@ -1,3 +1,4 @@
import type { UnsafeUnwrappedHeaders } from 'next/headers'
import type { FC } from 'react'
import { headers } from 'next/headers'
import Script from 'next/script'
@ -18,45 +19,54 @@ export type IGAProps = {
gaType: GaType
}
const GA: FC<IGAProps> = async ({
const extractNonceFromCSP = (cspHeader: string | null): string | undefined => {
if (!cspHeader)
return undefined
const nonceMatch = cspHeader.match(/'nonce-([^']+)'/)
return nonceMatch ? nonceMatch[1] : undefined
}
const GA: FC<IGAProps> = ({
gaType,
}) => {
if (IS_CE_EDITION)
return null
const nonce = process.env.NODE_ENV === 'production' ? (await headers()).get('x-nonce') ?? '' : ''
const cspHeader = process.env.NODE_ENV === 'production'
? (headers() as unknown as UnsafeUnwrappedHeaders).get('content-security-policy')
: null
const nonce = extractNonceFromCSP(cspHeader)
return (
<>
<Script
strategy="beforeInteractive"
async
src={`https://www.googletagmanager.com/gtag/js?id=${gaIdMaps[gaType]}`}
nonce={nonce ?? undefined}
>
</Script>
{/* Initialize dataLayer first */}
<Script
id="ga-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${gaIdMaps[gaType]}');
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag(){window.dataLayer.push(arguments);};
window.gtag('js', new Date());
window.gtag('config', '${gaIdMaps[gaType]}');
`,
}}
nonce={nonce ?? undefined}
>
</Script>
nonce={nonce}
/>
{/* Load GA script */}
<Script
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${gaIdMaps[gaType]}`}
nonce={nonce}
/>
{/* Cookie banner */}
<Script
id="cookieyes"
strategy="lazyOnload"
src="https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js"
nonce={nonce ?? undefined}
>
</Script>
nonce={nonce}
/>
</>
)
}
export default React.memo(GA)

View File

@ -1,5 +1,6 @@
'use client'
import type { MailRegisterResponse } from '@/service/use-common'
import Cookies from 'js-cookie'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -10,6 +11,20 @@ import Toast from '@/app/components/base/toast'
import { validPassword } from '@/config'
import { useMailRegister } from '@/service/use-common'
import { cn } from '@/utils/classnames'
import { sendGAEvent } from '@/utils/gtag'
const parseUtmInfo = () => {
const utmInfoStr = Cookies.get('utm_info')
if (!utmInfoStr)
return null
try {
return JSON.parse(utmInfoStr)
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
return null
}
}
const ChangePasswordForm = () => {
const { t } = useTranslation()
@ -55,11 +70,18 @@ const ChangePasswordForm = () => {
})
const { result } = res as MailRegisterResponse
if (result === 'success') {
// Track registration success event
trackEvent('user_registration_success', {
const utmInfo = parseUtmInfo()
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'email',
...utmInfo,
})
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'email',
...utmInfo,
})
Cookies.remove('utm_info') // Clean up: remove utm_info cookie
Toast.notify({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),

20
web/global.d.ts vendored
View File

@ -9,4 +9,22 @@ declare module 'lamejs/src/js/Lame';
declare module 'lamejs/src/js/BitStream';
declare module 'react-18-input-autosize';
export { }
declare global {
// Google Analytics gtag types
type GtagEventParams = {
[key: string]: unknown
}
type Gtag = {
(command: 'config', targetId: string, config?: GtagEventParams): void
(command: 'event', eventName: string, eventParams?: GtagEventParams): void
(command: 'js', date: Date): void
(command: 'set', config: GtagEventParams): void
}
// eslint-disable-next-line ts/consistent-type-definitions -- interface required for declaration merging
interface Window {
gtag?: Gtag
dataLayer?: unknown[]
}
}

14
web/utils/gtag.ts Normal file
View File

@ -0,0 +1,14 @@
/**
* Send Google Analytics event
* @param eventName - event name
* @param eventParams - event params
*/
export const sendGAEvent = (
eventName: string,
eventParams?: GtagEventParams,
): void => {
if (typeof window === 'undefined' || typeof (window as any).gtag !== 'function') {
return
}
(window as any).gtag('event', eventName, eventParams)
}