feat(analytics): add behavior and payment funnel tracking
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
# Cozsweet 全站行为埋点前端接入
|
||||
|
||||
> 发布状态(2026-07-14):自动页面/按钮采集 SDK 已部署 `pro` 预发,生产仍是旧版支付专项 SDK。前端应先接 `pro` 完成联调;生产自动采集要等 unified 下一次生产镜像发布后再启用。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
前端接入一次后,自动统计:
|
||||
|
||||
- 用户访问了哪个页面。
|
||||
- Next.js 站内路由切换。
|
||||
- 所有原生按钮、链接和带 `role="button"` 的控件点击。
|
||||
- 带 `data-analytics-key` 的自定义可点击组件。
|
||||
|
||||
支付漏斗另外上报明确业务事件,统计付费墙原因、套餐曝光、套餐点击、创建订单、打开支付页和最终支付结果。
|
||||
|
||||
## 2. 环境地址
|
||||
|
||||
| 环境 | SDK | 事件接口 |
|
||||
| --- | --- | --- |
|
||||
| pro 预发 | `https://proapi.banlv-ai.com/js/cozsweet-payment-analytics.js` | `https://proapi.banlv-ai.com/api/behavior/events` |
|
||||
| production 生产 | `https://api.banlv-ai.com/js/cozsweet-payment-analytics.js` | `https://api.banlv-ai.com/api/behavior/events` |
|
||||
|
||||
## 3. Next.js 根布局接入
|
||||
|
||||
只在根布局加载一次。预发必须先指定预发事件地址,否则 SDK 默认发送到生产。
|
||||
|
||||
```tsx
|
||||
import Script from 'next/script'
|
||||
|
||||
export function BehaviorAnalyticsScripts() {
|
||||
const isPro = process.env.NEXT_PUBLIC_APP_ENV === 'pro'
|
||||
const apiBase = isPro
|
||||
? 'https://proapi.banlv-ai.com'
|
||||
: 'https://api.banlv-ai.com'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script id="cozsweet-analytics-config" strategy="beforeInteractive">
|
||||
{`window.COZSWEET_ANALYTICS_ENDPOINT=${JSON.stringify(
|
||||
`${apiBase}/api/behavior/events`,
|
||||
)}`}
|
||||
</Script>
|
||||
<Script
|
||||
src={`${apiBase}/js/cozsweet-payment-analytics.js`}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
SDK 会自动生成并持久化:
|
||||
|
||||
- `anonymousId`:未登录用户标识。
|
||||
- `sessionId`:30 分钟会话标识。
|
||||
- `pagePath/pageTitle/referrer`。
|
||||
- `siteHost/pageUrl/UTM`。
|
||||
|
||||
登录成功或恢复会话后设置 Token,让事件同时绑定 `userId`:
|
||||
|
||||
```ts
|
||||
window.CozsweetPaymentAnalytics?.setToken(accessToken)
|
||||
```
|
||||
|
||||
## 4. 所有按钮点击
|
||||
|
||||
以下元素会自动上报 `eventType=element_click`,前端不需要在每个 `onClick` 里手工请求接口:
|
||||
|
||||
- `button`
|
||||
- `a[href]`
|
||||
- `[role="button"]`
|
||||
- `[role="link"]`
|
||||
- `input[type="button|submit|reset"]`
|
||||
- `[data-analytics-key]`
|
||||
|
||||
重要按钮必须提供稳定且不随语言变化的 `data-analytics-key`:
|
||||
|
||||
```tsx
|
||||
<button data-analytics-key="chat.send_message">Send</button>
|
||||
|
||||
<button data-analytics-key="recharge.open" data-analytics-label="Open recharge">
|
||||
Recharge
|
||||
</button>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
data-analytics-key="private_album.unlock"
|
||||
data-analytics-label="Unlock private album"
|
||||
onClick={unlockAlbum}
|
||||
/>
|
||||
```
|
||||
|
||||
纯 `div onClick` 如果没有 `role="button"`,必须添加 `data-analytics-key`,否则浏览器无法可靠识别它是按钮。
|
||||
|
||||
不需要统计的区域:
|
||||
|
||||
```tsx
|
||||
<div data-analytics-ignore>
|
||||
{/* 此节点及其子节点不自动记录点击 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
程序触发、没有真实 DOM 点击时可以手工记录:
|
||||
|
||||
```ts
|
||||
window.CozsweetPaymentAnalytics?.elementClick(
|
||||
'chat.retry_generation',
|
||||
'Retry generation',
|
||||
{ messageId },
|
||||
)
|
||||
```
|
||||
|
||||
不要把聊天正文、邮箱、手机号、Token 或支付参数放进 `data-analytics-key`、标签或 metadata。
|
||||
|
||||
## 5. 支付漏斗必须手工上报
|
||||
|
||||
通用按钮点击只能知道“点了某个按钮”,不能知道套餐价格、订单 ID 和进入充值页的原因。以下事件必须在支付业务代码对应步骤调用。
|
||||
|
||||
```ts
|
||||
const analytics = window.CozsweetPaymentAnalytics
|
||||
|
||||
analytics?.paywallShown('daily_chat_limit', {
|
||||
entryPoint: 'chat_input',
|
||||
isVip: false,
|
||||
})
|
||||
|
||||
analytics?.rechargeModalOpen('chat_input', 'daily_chat_limit')
|
||||
|
||||
plans.forEach((plan, index) => {
|
||||
analytics?.planImpression(plan, index + 1)
|
||||
})
|
||||
|
||||
analytics?.planClick(plan, {
|
||||
entryPoint: 'paywall_modal',
|
||||
triggerReason: 'daily_chat_limit',
|
||||
})
|
||||
|
||||
analytics?.createOrderStart(plan, payChannel)
|
||||
|
||||
try {
|
||||
const response = await createOrder({
|
||||
planId: plan.planId,
|
||||
payChannel,
|
||||
autoRenew,
|
||||
})
|
||||
const data = response.data
|
||||
const orderId = data.orderId
|
||||
const checkoutUrl = analytics?.extractCheckoutUrl(data)
|
||||
|
||||
analytics?.createOrderSuccess(plan, orderId, payChannel)
|
||||
if (checkoutUrl) {
|
||||
analytics?.checkoutOpened(orderId, plan, payChannel, checkoutUrl)
|
||||
window.location.href = checkoutUrl
|
||||
} else {
|
||||
analytics?.checkoutFailed(
|
||||
orderId,
|
||||
plan,
|
||||
'missing_checkout_url',
|
||||
payChannel,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
analytics?.createOrderFailed(
|
||||
plan,
|
||||
error instanceof Error ? error.message : 'create_order_error',
|
||||
payChannel,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
轮询订单状态:
|
||||
|
||||
```ts
|
||||
analytics?.statusPollStart(orderId, plan)
|
||||
|
||||
analytics?.statusPollUpdate(orderId, data.status, plan, {
|
||||
expiresAt: data.expiresAt,
|
||||
expiresInSeconds: data.expiresInSeconds,
|
||||
})
|
||||
|
||||
// 前端主动放弃轮询且订单仍未进入终态时调用
|
||||
analytics?.statusPollTimeout(orderId, plan)
|
||||
```
|
||||
|
||||
## 6. 固定事件名
|
||||
|
||||
| 步骤 | 事件名 | 必要 metadata |
|
||||
| --- | --- | --- |
|
||||
| 触发付费墙 | `paywall_shown` | `triggerReason`, `entryPoint` |
|
||||
| 打开充值页 | `recharge_modal_open` | `triggerReason`, `entryPoint` |
|
||||
| 套餐曝光 | `payment_plan_impression` | `planId`, `amountCents`, `currency`, `position` |
|
||||
| 点击套餐 | `payment_plan_click` | `planId`, `amountCents`, `currency` |
|
||||
| 开始建单 | `payment_create_order_start` | `planId`, `payChannel` |
|
||||
| 建单成功 | `payment_create_order_success` | `planId`, `orderId`, `payChannel` |
|
||||
| 建单失败 | `payment_create_order_failed` | `planId`, `reason`, `payChannel` |
|
||||
| 打开支付页 | `payment_checkout_opened` | `planId`, `orderId`, `payChannel` |
|
||||
| 支付页缺失/打开失败 | `payment_checkout_failed` | `orderId`, `reason` |
|
||||
| 支付状态 | `payment_status_paid/failed/expired` | `orderId`, `status` |
|
||||
| 轮询超时 | `payment_status_poll_timeout` | `orderId` |
|
||||
|
||||
`triggerReason` 使用固定值:
|
||||
|
||||
- `daily_chat_limit`
|
||||
- `private_topic_limit`
|
||||
- `insufficient_credits`
|
||||
- `sidebar_recharge`
|
||||
- `vip_cta`
|
||||
- `ad_landing`
|
||||
- `manual_recharge`
|
||||
- `unknown`
|
||||
|
||||
不要继续使用已经废弃的 `weekly_chat_limit` 作为新事件原因;当前免费聊天口径是每日额度。
|
||||
|
||||
## 7. 请求格式
|
||||
|
||||
SDK 最终批量发送:
|
||||
|
||||
```http
|
||||
POST /api/behavior/events
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN> // 有登录 Token 时携带
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"eventType": "element_click",
|
||||
"eventName": "element_click",
|
||||
"anonymousId": "browser-uuid",
|
||||
"sessionId": "session-uuid",
|
||||
"pagePath": "/chat",
|
||||
"pageTitle": "Chat",
|
||||
"elementKey": "recharge.open",
|
||||
"elementText": "Recharge",
|
||||
"elementTag": "button",
|
||||
"metadata": {
|
||||
"siteHost": "cozsweet.com",
|
||||
"pageUrl": "https://cozsweet.com/chat"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{"ok": true, "inserted": 1}
|
||||
```
|
||||
|
||||
埋点失败不能阻塞聊天、解锁或支付流程。SDK 已吞掉网络异常,业务代码不要 `await` 埋点后再继续支付。
|
||||
|
||||
## 8. 联调验收
|
||||
|
||||
1. 打开浏览器 Network,筛选 `behavior/events`。
|
||||
2. 首次打开页面应看到 `page_view`。
|
||||
3. Next.js 路由切换后应再次看到新 `pagePath` 的 `page_view`。
|
||||
4. 点击任意按钮应看到 `element_click` 和稳定 `elementKey`。
|
||||
5. 从付费墙点击一个套餐,依次检查专用支付事件及 `planId`。
|
||||
6. 建单成功后必须看到同一 `orderId` 出现在建单、支付页和状态事件中。
|
||||
7. 接口响应必须为 `ok=true` 且 `inserted>0`。
|
||||
8. Manager 选择同一日期范围,确认数据出现;统计不是实时推送时,刷新页面即可。
|
||||
|
||||
## 9. 当前线上为 0 的原因
|
||||
|
||||
截至 2026-07-14,线上 `cozsweet.com` 页面及加载的前端 JS 中没有找到本 SDK,也没有找到支付漏斗事件代码。数据库近期大部分 `element_click/page_view` 来自 Manager 后台自己的追踪,而不是 Cozsweet 用户端。后端只能自动记录建单开始/成功/失败,无法替前端知道付费墙是否展示、套餐是否曝光、用户点击了哪个套餐、支付页是否真正打开,因此这些步骤显示 0 是符合当前数据事实的。
|
||||
Reference in New Issue
Block a user