From 2b8be2c869cfa072d2035f565c66de6c6433b62d Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 7 Nov 2025 14:21:09 +0800 Subject: [PATCH] Add posthog --- .../base/amplitude/AmplitudeProvider.tsx | 12 +++- web/app/components/posthog-provider.tsx | 57 ++++++++++++++++ web/app/layout.tsx | 22 ++++--- web/next.config.js | 2 + web/package.json | 5 +- web/pnpm-lock.yaml | 54 ++++++++++++++- web/utils/posthog.ts | 65 +++++++++++++++++++ 7 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 web/app/components/posthog-provider.tsx create mode 100644 web/utils/posthog.ts diff --git a/web/app/components/base/amplitude/AmplitudeProvider.tsx b/web/app/components/base/amplitude/AmplitudeProvider.tsx index e496c01594..6725da818c 100644 --- a/web/app/components/base/amplitude/AmplitudeProvider.tsx +++ b/web/app/components/base/amplitude/AmplitudeProvider.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import React, { useEffect } from 'react' import * as amplitude from '@amplitude/analytics-browser' +import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser' export type IAmplitudeProps = { apiKey?: string @@ -18,7 +19,12 @@ const AmplitudeProvider: FC = ({ // return // } - // Initialize Amplitude with proxy configuration to bypass CSP + // Create Session Replay plugin instance + const sessionReplay = sessionReplayPlugin({ + sampleRate: 1, + }) + + // Initialize Amplitude with proxy configuration to bypass CSP and Session Replay amplitude.init(apiKey, { defaultTracking: { sessions: true, @@ -31,10 +37,10 @@ const AmplitudeProvider: FC = ({ // Use Next.js proxy to bypass CSP restrictions serverUrl: '/api/amplitude/2/httpapi', }) - + amplitude.add(sessionReplay) // Log initialization success in development if (process.env.NODE_ENV === 'development') - console.log('[Amplitude] Initialized successfully, API Key:', apiKey) + console.log('[Amplitude] Initialized successfully with Session Replay, API Key:', apiKey) }, [apiKey]) // This is a client component that renders nothing diff --git a/web/app/components/posthog-provider.tsx b/web/app/components/posthog-provider.tsx new file mode 100644 index 0000000000..80b2589e90 --- /dev/null +++ b/web/app/components/posthog-provider.tsx @@ -0,0 +1,57 @@ +'use client' + +import type { FC } from 'react' +import React, { useEffect } from 'react' +import { usePathname, useSearchParams } from 'next/navigation' +import posthog from 'posthog-js' + +const PostHogProvider: FC = () => { + const pathname = usePathname() + const searchParams = useSearchParams() + + useEffect(() => { + posthog.init('phc_Nqsm7RqSX7ZUbH9C47ZRfiUsVBCiLZIrapbWjHAYTBV', { + api_host: 'https://us.i.posthog.com', + person_profiles: 'identified_only', // 为已识别用户创建 profile 并存储 UTM + autocapture: true, // 自动捕获用户交互 + capture_exceptions: true, // 捕获异常 + }) + + // 初始化后,确保捕获 UTM 参数 + // PostHog 会自动将首次访问的 UTM 保存为 Initial UTM + if (typeof window !== 'undefined' && window.location.search) { + const urlParams = new URLSearchParams(window.location.search) + const utmParams: Record = {} + + // 提取所有 UTM 参数 + const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] + utmKeys.forEach((key) => { + const value = urlParams.get(key) + if (value) + utmParams[key] = value + }) + + // 如果有 UTM 参数,注册为持久属性 + if (Object.keys(utmParams).length > 0) + posthog.register(utmParams) + } + }, []) + + // 追踪页面浏览 + useEffect(() => { + if (pathname && posthog.__loaded) { + let url = window.origin + pathname + if (searchParams.toString()) + url = `${url}?${searchParams.toString()}` + + posthog.capture('$pageview', { + $current_url: url, + }) + } + }, [pathname, searchParams]) + + // This is a client component that renders nothing + return null +} + +export default React.memo(PostHogProvider) diff --git a/web/app/layout.tsx b/web/app/layout.tsx index c83ea7fd85..515a35c85f 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -3,6 +3,7 @@ import type { Viewport } from 'next' import I18nServer from './components/i18n-server' import BrowserInitializer from './components/browser-initializer' import SentryInitializer from './components/sentry-initializer' +import PostHogProvider from './components/posthog-provider' import { getLocaleOnServer } from '@/i18n-config/server' import { TanstackQueryInitializer } from '@/context/query-client' import { ThemeProvider } from 'next-themes' @@ -93,15 +94,18 @@ const LocaleLayout = async ({ enableColorScheme={false} > - - - - - {children} - - - - + <> + + + + + + {children} + + + + + diff --git a/web/next.config.js b/web/next.config.js index 834316f7a8..98475d7d16 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -131,6 +131,8 @@ const nextConfig = { '@heroicons/react' ], }, + // This is required to support PostHog trailing slash API requests + skipTrailingSlashRedirect: true, // fix all before production. Now it slow the develop speed. eslint: { // Warning: This allows production builds to successfully complete even if diff --git a/web/package.json b/web/package.json index 4f152274e7..1db9950715 100644 --- a/web/package.json +++ b/web/package.json @@ -44,6 +44,7 @@ "knip": "knip" }, "dependencies": { + "@amplitude/plugin-session-replay-browser": "^1.23.2", "@amplitude/unified": "1.0.0-beta.9", "@emoji-mart/data": "^1.2.1", "@floating-ui/react": "^0.26.28", @@ -106,6 +107,8 @@ "next-pwa": "^5.6.0", "next-themes": "^0.4.6", "pinyin-pro": "^3.27.0", + "posthog-js": "^1.288.0", + "posthog-node": "^5.11.1", "qrcode.react": "^4.2.0", "qs": "^6.14.0", "react": "19.1.1", @@ -131,9 +134,9 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "scheduler": "^0.26.0", - "socket.io-client": "^4.8.1", "semver": "^7.7.3", "sharp": "^0.33.5", + "socket.io-client": "^4.8.1", "sortablejs": "^1.15.6", "swr": "^2.3.6", "tailwind-merge": "^2.6.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5ddf271f86..c3550296c1 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: .: dependencies: + '@amplitude/plugin-session-replay-browser': + specifier: ^1.23.2 + version: 1.23.2(@amplitude/rrweb@2.0.0-alpha.33)(rollup@2.79.2) '@amplitude/unified': specifier: 1.0.0-beta.9 version: 1.0.0-beta.9(@amplitude/rrweb@2.0.0-alpha.33)(rollup@2.79.2) @@ -246,6 +249,12 @@ importers: pinyin-pro: specifier: ^3.27.0 version: 3.27.0 + posthog-js: + specifier: ^1.288.0 + version: 1.288.0 + posthog-node: + specifier: ^5.11.1 + version: 5.11.1 qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.1.1) @@ -2619,6 +2628,9 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@posthog/core@1.5.1': + resolution: {integrity: sha512-8fdEzfvdStr45iIncTD+gnqp45UBTUpRK/bwB4shP5usCKytnPIeilU8rIpNBOVjJPwfW+2N8yWhQ0l14x191Q==} + '@preact/signals-core@1.12.1': resolution: {integrity: sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==} @@ -4304,6 +4316,9 @@ packages: core-js-pure@3.46.0: resolution: {integrity: sha512-NMCW30bHNofuhwLhYPt66OLOKTMbOhgTTatKVbaQC3KRHpTCiRIBYvtshr+NBYSnBxwAFhjW/RfJ0XbIjS16rw==} + core-js@3.46.0: + resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -7064,6 +7079,16 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.288.0: + resolution: {integrity: sha512-KOeF8PK/zxBuFB4b3FVkj5JxSWAfSOrfDVvWj5VrJNBGYqr8igDbAl10huFv9NB4/K9XeIWQ7AzPPGV4D3lbEA==} + + posthog-node@5.11.1: + resolution: {integrity: sha512-P6rtzdCVvS718r011x0W0cwmJo7gfP5YWXiWh0/S3OL+pnHtcqbWHDjrtRxN/IrMkjZWzMU4xDze5vRK/cZ23w==} + engines: {node: '>=20'} + + preact@10.27.2: + resolution: {integrity: sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -8387,6 +8412,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + web-vitals@5.1.0: resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} @@ -8752,7 +8780,7 @@ snapshots: '@amplitude/rrweb-packer@2.0.0-alpha.32': dependencies: - '@amplitude/rrweb-types': 2.0.0-alpha.32 + '@amplitude/rrweb-types': 2.0.0-alpha.33 fflate: 0.4.8 '@amplitude/rrweb-plugin-console-record@2.0.0-alpha.32(@amplitude/rrweb@2.0.0-alpha.33)': @@ -8762,7 +8790,7 @@ snapshots: '@amplitude/rrweb-record@2.0.0-alpha.32': dependencies: '@amplitude/rrweb': 2.0.0-alpha.33 - '@amplitude/rrweb-types': 2.0.0-alpha.32 + '@amplitude/rrweb-types': 2.0.0-alpha.33 '@amplitude/rrweb-snapshot@2.0.0-alpha.33': dependencies: @@ -11127,6 +11155,10 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@posthog/core@1.5.1': + dependencies: + cross-spawn: 7.0.6 + '@preact/signals-core@1.12.1': {} '@radix-ui/primitive@1.1.3': {} @@ -13059,6 +13091,8 @@ snapshots: core-js-pure@3.46.0: {} + core-js@3.46.0: {} + core-util-is@1.0.3: {} cose-base@1.0.3: @@ -16627,6 +16661,20 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.288.0: + dependencies: + '@posthog/core': 1.5.1 + core-js: 3.46.0 + fflate: 0.4.8 + preact: 10.27.2 + web-vitals: 4.2.4 + + posthog-node@5.11.1: + dependencies: + '@posthog/core': 1.5.1 + + preact@10.27.2: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -18108,6 +18156,8 @@ snapshots: web-namespaces@2.0.1: {} + web-vitals@4.2.4: {} + web-vitals@5.1.0: {} webidl-conversions@4.0.2: {} diff --git a/web/utils/posthog.ts b/web/utils/posthog.ts new file mode 100644 index 0000000000..8d0748d9e8 --- /dev/null +++ b/web/utils/posthog.ts @@ -0,0 +1,65 @@ +import posthog from 'posthog-js' +import type { UserProfileResponse } from '@/models/common' + +/** + * 从 URL 中提取 UTM 参数 + */ +function getUTMParams(): Record { + const utmParams: Record = {} + const urlParams = new URLSearchParams(window.location.search) + + const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] + + utmKeys.forEach((key) => { + const value = urlParams.get(key) + if (value) + utmParams[key] = value + }) + + return utmParams +} + +/** + * 识别用户身份并上报到 PostHog + * @param userProfile - 完整的用户资料对象 + */ +export function identifyUser(userProfile: UserProfileResponse) { + // 检查 PostHog 是否已加载 + if (!posthog.__loaded) + return + + // 检查是否有有效的用户 ID + if (!userProfile?.id) + return + + try { + const properties: Record = {} + + // 用户基本信息 + if (userProfile.email) + properties.email = userProfile.email + if (userProfile.name) + properties.name = userProfile.name + if (userProfile.created_at) + properties.created_at = userProfile.created_at + if (userProfile.interface_language) + properties.interface_language = userProfile.interface_language + if (userProfile.interface_theme) + properties.interface_theme = userProfile.interface_theme + if (userProfile.timezone) + properties.timezone = userProfile.timezone + + // 添加当前 URL 的 UTM 参数(如果有) + const utmParams = getUTMParams() + if (Object.keys(utmParams).length > 0) { + Object.entries(utmParams).forEach(([key, value]) => { + properties[key] = value + }) + } + + posthog.identify(userProfile.id, properties) + } + catch (error) { + console.error('[PostHog] identify 失败:', error) + } +}