GOOGLE ADS TRACKING
本地来源:Knowledge/World/项目/文档/ads-tracking/GOOGLE_ADS_TRACKING.md
SeedVR2 谷歌广告转化追踪(项目版大白话)
先说结论:这个项目已经“装好收款机”了(gtag 脚本 + GA4 页面浏览),我们只差在关键动作上“按一下按钮”把转化打出去。下面是按本项目的真实代码位置写的 SOP。
TL;DR(先看这一段就够用)
- Google Ads 后台先建转化:至少建
Purchase、SignUp、BeginCheckout。 - 代码里的 4 个打点已接上:注册成功、登录成功、点“去付款”、支付成功(没有就按清单补)。
- 支付成功打点要带金额/币种/订单号:项目里已返回,别漏。
- 想区分广告 vs 自然:需要做 UTM/GCLID 入库(文末有方案)。
1. 这个项目现在已经有啥(不用重装)
- gtag 脚本:在
src/components/layout/deferred-third-party.tsx里延迟加载。 - GA4 页面浏览:
src/analytics/google-analytics.tsx已经在发page_view。 - ID 管理:
- GA4:
.env里的NEXT_PUBLIC_GOOGLE_ANALYTICS_ID。 - Google Ads:必须通过环境变量配置
NEXT_PUBLIC_GOOGLE_ADS_IDS="AW-你的ID"。 - 代码不包含任何硬编码的 Ads ID,必须自己配置。
- 追踪开关:
NEXT_PUBLIC_ENABLE_ADS_TRACKING不是false才会启用(生产环境才会发)。
目前缺的: 广告归因入库(UTM/GCLID)还没做;One Tap 新用户默认只打 login,想区分新老要补。
1.1 当前数据口径(/admin/ads)
- 注册数 =
user表所有用户的createdAt(全站注册,不区分来源) - 购买数 =
payment表所有paid=true的订单 - 无法区分:自然流量注册 vs 广告带来的注册、直接购买 vs 广告归因购买
- 目前能做的:看整体漏斗趋势(注册→购买转化率),对比投/不投广告期间的变化
2. 要追踪哪些东西(建议主线)
| 事件 | 什么时候触发 | 代码位置(本项目) | 重要程度 |
|---|---|---|---|
purchase |
支付成功那一刻 | src/components/payment/payment-page.tsx |
必做 |
begin_checkout |
点击"购买/充值"按钮 | src/components/pricing/create-checkout-button.tsx + src/components/settings/credits/credit-checkout-button.tsx |
重要 |
sign_up |
注册成功(邮箱或 OAuth;One Tap 新用户需补) | src/components/auth/register-form.tsx(邮箱)+ src/app/[locale]/auth/post-oauth/post-oauth-client.tsx(OAuth) |
重要 |
login |
登录成功(老用户/One Tap) | src/components/auth/login-form.tsx + src/app/[locale]/auth/post-oauth/post-oauth-client.tsx + src/components/auth/one-tap.tsx |
可选 |
ai_generation |
AI 生成成功 | 你的生成完成回调处 | 可选 |
大白话:先把"钱"和"新用户"追上,再考虑功能使用类的事件。
✅ OAuth 注册也会追踪:Google/GitHub 登录时,如果用户是新注册的(2分钟内创建),会自动触发
sign_up而不是login。⚠️ One Tap 新用户:当前代码里只打了
login,如果你要把 One Tap 新用户算进sign_up,需要补新用户判断。
3. 需要改哪些文件(清单)
已在项目里(对照检查,没就补):
- src/lib/gtag.ts:统一打点函数。
- src/components/auth/register-form.tsx:注册成功打 sign_up。
- src/components/auth/login-form.tsx + src/app/[locale]/auth/post-oauth/post-oauth-client.tsx:登录事件。
- src/components/pricing/create-checkout-button.tsx:点购买按钮打 begin_checkout。
- src/components/settings/credits/credit-checkout-button.tsx:点积分包按钮打 begin_checkout。
- src/components/payment/payment-page.tsx:支付成功打 purchase(注意避免重复触发)。
- src/app/api/payment/check-status/route.ts:已返回金额/币种/订单号给前端。
可以再补的:
- src/components/auth/one-tap.tsx:One Tap 新用户也打 sign_up(当前只打 login)。
- AI 生成相关组件:打 ai_generation。
4. Google Ads 后台先准备(必须)
在 Google Ads 后台创建转化动作:
| 转化名 | 类别 | 计数方式 | 建议值 |
|---|---|---|---|
SeedVR2_Purchase |
Purchase | Every | 动态金额 |
SeedVR2_SignUp |
Sign-up | One | 固定值(如 $5) |
SeedVR2_BeginCheckout |
Begin checkout | One | 可不填 |
创建完会拿到两样东西:
- Conversion ID(长得像 AW-123456789)
- Conversion Label(一串字母)
这两个一定要记下来,后面要用。
5. 代码实现(按本项目落地)
5.1 先加环境变量(可选但推荐)
把转化 Label 单独放 env,后面换号不改代码:
# .env / .env.example
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID="G-XXXXXXXXXX"
NEXT_PUBLIC_GOOGLE_ADS_IDS="AW-你的ADSID" # 建议只留你当前账户
NEXT_PUBLIC_ENABLE_ADS_TRACKING="true"
NEXT_PUBLIC_GADS_PURCHASE_LABEL="xxxxxxxx"
NEXT_PUBLIC_GADS_SIGNUP_LABEL="yyyyyyyy"
NEXT_PUBLIC_GADS_BEGIN_CHECKOUT_LABEL="zzzzzzzz"
提醒:代码不包含任何默认 Ads ID,必须配置环境变量才能发送转化事件。
NEXT_PUBLIC_GOOGLE_ADS_IDS支持多个,用逗号分隔,但建议只保留你自己的账号,避免重复统计。
5.2 在 src/lib/gtag.ts 里加统一打点函数
下面这段是“能直接抄”的思路(项目里已存在,缺了就补;别重复粘两遍):
// src/lib/gtag.ts
type GtagWindow = typeof window & {
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
};
const adsLabels = {
purchase: process.env.NEXT_PUBLIC_GADS_PURCHASE_LABEL,
signUp: process.env.NEXT_PUBLIC_GADS_SIGNUP_LABEL,
beginCheckout: process.env.NEXT_PUBLIC_GADS_BEGIN_CHECKOUT_LABEL,
} as const;
const getGtag = () => {
if (typeof window === 'undefined') return null;
const w = window as GtagWindow;
w.dataLayer = w.dataLayer || [];
if (!w.gtag) {
w.gtag = (...args: unknown[]) => {
w.dataLayer?.push(args);
};
}
return w.gtag;
};
const getGa4Id = () => gtagIds.find((id) => id.startsWith('G-'));
const sendAdsConversion = (
labelKey: keyof typeof adsLabels,
params: Record<string, unknown>
) => {
const gtag = getGtag();
if (!gtag) return;
const label = adsLabels[labelKey];
if (!label) return;
for (const adsId of gtagIds.filter((id) => id.startsWith('AW-'))) {
gtag('event', 'conversion', {
send_to: `${adsId}/${label}`,
...params,
});
}
};
export const trackSignUp = (email?: string, method = 'email') => {
const gtag = getGtag();
if (!gtag) return;
const ga4Id = getGa4Id();
if (ga4Id) {
gtag('event', 'sign_up', {
send_to: ga4Id,
method,
});
}
sendAdsConversion('signUp', {
user_data: email ? { email } : undefined,
});
};
export const trackBeginCheckout = (value?: number, currency = 'USD') => {
const gtag = getGtag();
if (!gtag) return;
const ga4Id = getGa4Id();
if (ga4Id) {
gtag('event', 'begin_checkout', {
send_to: ga4Id,
value,
currency,
});
}
sendAdsConversion('beginCheckout', { value, currency });
};
export const trackPurchase = (params: {
transactionId: string;
value: number;
currency?: string;
email?: string;
}) => {
const gtag = getGtag();
if (!gtag) return;
const currency = params.currency || 'USD';
const ga4Id = getGa4Id();
if (ga4Id) {
gtag('event', 'purchase', {
send_to: ga4Id,
transaction_id: params.transactionId,
value: params.value,
currency,
});
}
sendAdsConversion('purchase', {
transaction_id: params.transactionId,
value: params.value,
currency,
user_data: params.email ? { email: params.email } : undefined,
});
};
大白话:这段就是“统一入口”,后面只要调用 trackSignUp / trackBeginCheckout / trackPurchase 就行。
现在项目里还多了 NEXT_PUBLIC_ENABLE_ADS_TRACKING 的开关判断,记得别关掉。
5.3 注册成功打 sign_up
文件: src/components/auth/register-form.tsx
在 onSuccess 里加一行:
trackSignUp(values.email, 'email');
5.4 登录/注册事件追踪
邮箱登录: src/components/auth/login-form.tsx 的 onSuccess 打 login。
邮箱注册: src/components/auth/register-form.tsx 的 onSuccess 打 sign_up。
OAuth(Google/GitHub): src/app/[locale]/auth/post-oauth/post-oauth-client.tsx 会自动判断:
- 如果用户是新注册(createdAt 在 2 分钟内),打 sign_up
- 如果是老用户登录,打 login
One Tap(Google One Tap): src/components/auth/one-tap.tsx 目前只打了 login,如果你要区分新用户,就按上面的新用户判断逻辑补 sign_up。
// post-oauth-client.tsx 核心逻辑
const isNewUser = (createdAt) => {
const diffMs = Date.now() - new Date(createdAt).getTime();
return diffMs < 2 * 60 * 1000; // 2分钟内
};
// 在 OAuth 回调中
const user = session.data?.user;
if (user && isNewUser(user.createdAt)) {
trackSignUp(user.email, 'oauth'); // 新用户
} else {
trackLogin('oauth'); // 老用户
}
5.5 点购买按钮打 begin_checkout
文件 1: src/components/pricing/create-checkout-button.tsx
在 handleClick 开头加:
trackBeginCheckout(priceValue, 'USD');
priceValue 从哪里来?
- 订阅套餐:findPriceInPlan 拿到 amount 或 yearlyTotal(单位是分)。
- 积分包:websiteConfig.credits.packages[packageKey].price.amount(单位是分)。
- 记住:这里是 分,要 /100 变成美元。
文件 2: src/components/settings/credits/credit-checkout-button.tsx 同理。
5.6 支付成功打 purchase
文件: src/components/payment/payment-page.tsx
在 data.status === 'completed' 时打点,注意 只打一次:
const hasTracked = useRef(false);
if (data.status === 'completed' && !hasTracked.current) {
hasTracked.current = true;
trackPurchase({
transactionId: data.transactionId,
value: data.value,
currency: data.currency,
email: data.customerEmail,
});
}
这里的数据来自哪里?看下一步。
5.7 支付状态接口返回金额/币种(关键补漏)
文件: src/app/api/payment/check-status/route.ts
现在项目已经把金额/币种/订单号一起返回了;如果你的分支还没有,按下面思路补:
return NextResponse.json({
status: 'completed',
transactionId: record.sessionId || record.id,
value: (record.amountPaidCents ?? expectedAmount ?? 0) / 100,
currency: (record.currency || expectedCurrency || 'USD').toUpperCase(),
customerEmail: session?.user?.email, // 或从 payment 元数据里拿
});
大白话: 支付成功打点必须有“多少钱+什么币种+唯一订单号”,不然 Ads 统计会乱。
6. Enhanced Conversions(建议开)
这玩意就是“提高准确率”,尤其是 Safari/Firefox 会丢数据时。
步骤:
1. Google Ads 后台开启“增强型转化”。
2. 前端打点时带 user_data.email(上面的 trackSignUp/trackPurchase 已经写了)。
gtag 会自动做 SHA256 加密,不需要你手动 hash。
7. 怎么测试(别在 dev 里瞎猜)
因为本项目 只有 production 才加载 gtag,所以测试要这样:
pnpm build
pnpm start
然后用下面方式检查:
- Chrome 插件 Tag Assistant
- Network 面板搜 googleadservices.com/pagead/conversion
看到 conversion 请求就算打通。
8. 常见坑(提前避雷)
- 开发环境没有追踪:这是正常的,必须跑
pnpm start。 - 追踪开关被关了:
NEXT_PUBLIC_ENABLE_ADS_TRACKING="false"会导致所有打点失效。 - 金额是分不是美元:
amount在配置里是分,要/100。 - 支付接口轮询会重复打点:一定要
useRef只打一次。 - 多个 Ads ID 可能重复统计:
NEXT_PUBLIC_GOOGLE_ADS_IDS只填你自己的账户 ID。 - gtag 是延迟加载的:最好用上面的
getGtag()兜底,先把事件塞进 dataLayer。
9. 最终验收清单
- [ ] Ads 后台已建
Purchase/SignUp/BeginCheckout转化 - [ ]
.env已填好 Conversion Label - [ ] 注册成功能打
sign_up - [ ] 点购买按钮能打
begin_checkout - [ ] 支付成功能打
purchase(带金额/币种/订单号) - [ ] Tag Assistant 能看到 conversion 请求
- [ ] 24-48 小时 Ads 后台显示"正在记录转化"
10. UTM 参数追踪(广告归因)
10.1 为什么需要 UTM 追踪?
问题: 上面的 gtag 转化追踪只能告诉 Google Ads "有人注册/购买了",但你的数据库里看不到这个用户是从哪个广告来的。
当前数据来源:
- 注册数 = user 表所有用户的 createdAt
- 购买数 = payment 表所有 paid=true 的订单
结果: 无法区分自然流量注册 vs 广告带来的注册、直接购买 vs 广告归因购买。
场景:
- /admin/ads 页面显示的注册数 = 全站所有注册,不区分来源
- 你投了广告,也有自然流量,分不清哪个渠道效果好
- 想在自己的后台看"广告用户 vs 自然用户"的转化率对比
解决方案: UTM 参数追踪 —— 用户从广告链接进来时,把来源信息存到数据库。
更完整的归因方案对比:
| 方案 | 原理 | 复杂度 |
|---|---|---|
| UTM 参数 | 注册时保存 utm_source/utm_medium/utm_campaign |
中等 |
| GCLID 追踪 | 保存 Google Ads 点击 ID,后续关联转化 | 中等 |
| Google Ads API | 直接从 Ads 后台拉取归因转化数据 | 高 |
10.2 项目现状(截至目前)
| 功能 | 状态 | 说明 |
|---|---|---|
| gtag 转化事件 | ✅ 已实现 | sign_up, purchase, begin_checkout |
| UTM 参数追踪 | ❌ 未实现 | 数据库没有存储用户来源 |
| 联盟营销追踪 | ✅ 已实现 | affonso_referral, promotekit_referral(但这是联盟,不是广告来源) |
| One Tap 新用户 sign_up | ❌ 未实现 | 目前 One Tap 只打 login |
10.3 UTM 参数是什么?
Google Ads 广告链接通常长这样:
https://seedvr2.net/?utm_source=google&utm_medium=cpc&utm_campaign=brand_search&utm_term=ai+video+upscaler&gclid=xxxxx
| 参数 | 含义 | 示例值 |
|---|---|---|
utm_source |
流量来源 | google, facebook, twitter |
utm_medium |
媒介类型 | cpc(付费点击), organic, email |
utm_campaign |
广告系列名称 | brand_search, competitor_keywords |
utm_term |
关键词(可选) | ai video upscaler |
utm_content |
广告内容(可选) | ad_variant_a |
gclid |
Google Click ID(自动) | Google Ads 自动附加 |
10.4 实现方案(本项目适配版)
步骤 1:数据库字段 + Better Auth 额外字段
文件: src/db/schema.ts + src/lib/auth.ts
在 user 表添加 UTM 字段(可空),同时在 Better Auth 里声明额外字段,才能注册/更新时写进去:
// src/db/schema.ts(示意)
utmSource: text('utm_source'),
utmMedium: text('utm_medium'),
utmCampaign: text('utm_campaign'),
utmTerm: text('utm_term'),
utmContent: text('utm_content'),
gclid: text('gclid'),
referrerUrl: text('referrer_url'),
// src/lib/auth.ts(user.additionalFields 里追加)
utmSource: { type: 'string', required: false },
utmMedium: { type: 'string', required: false },
utmCampaign: { type: 'string', required: false },
utmTerm: { type: 'string', required: false },
utmContent: { type: 'string', required: false },
gclid: { type: 'string', required: false },
referrerUrl: { type: 'string', required: false },
然后运行迁移:
pnpm db:generate
pnpm db:migrate
步骤 2:前端捕获 UTM 参数(首落地页就存)
新建文件: src/hooks/use-utm-capture.ts
'use client';
import { useEffect } from 'react';
const UTM_STORAGE_KEY = 'seedvr2_utm_params';
const UTM_EXPIRY_DAYS = 30;
export interface UtmParams {
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmTerm?: string;
utmContent?: string;
gclid?: string;
referrerUrl?: string;
capturedAt: number;
}
export function useUtmCapture() {
useEffect(() => {
if (typeof window === 'undefined') return;
const existing = localStorage.getItem(UTM_STORAGE_KEY);
if (existing) {
try {
const parsed = JSON.parse(existing) as UtmParams;
const expiryMs = UTM_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
if (Date.now() - parsed.capturedAt < expiryMs) return;
} catch {
// ignore parse error
}
}
const params = new URLSearchParams(window.location.search);
const utmSource = params.get('utm_source');
const utmMedium = params.get('utm_medium');
const utmCampaign = params.get('utm_campaign');
const utmTerm = params.get('utm_term');
const utmContent = params.get('utm_content');
const gclid = params.get('gclid');
if (utmSource || utmMedium || utmCampaign || gclid) {
const utmData: UtmParams = {
utmSource: utmSource || undefined,
utmMedium: utmMedium || undefined,
utmCampaign: utmCampaign || undefined,
utmTerm: utmTerm || undefined,
utmContent: utmContent || undefined,
gclid: gclid || undefined,
referrerUrl: document.referrer || undefined,
capturedAt: Date.now(),
};
localStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(utmData));
}
}, []);
}
export function getStoredUtmParams(): UtmParams | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem(UTM_STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
export function clearStoredUtmParams() {
if (typeof window === 'undefined') return;
localStorage.removeItem(UTM_STORAGE_KEY);
}
步骤 3:全局启用捕获 + 登录后同步到用户
新建文件: src/analytics/utm-attribution.tsx
'use client';
import { authClient } from '@/lib/auth-client';
import { useSession } from '@/hooks/use-session';
import { useEffect } from 'react';
import {
useUtmCapture,
getStoredUtmParams,
clearStoredUtmParams,
} from '@/hooks/use-utm-capture';
export function UtmAttribution() {
useUtmCapture();
const { data } = useSession();
useEffect(() => {
if (!data?.user) return;
const utm = getStoredUtmParams();
if (!utm) return;
if (data.user.utmSource || data.user.gclid) {
clearStoredUtmParams();
return;
}
const sync = async () => {
try {
await authClient.updateUser({
utmSource: utm.utmSource,
utmMedium: utm.utmMedium,
utmCampaign: utm.utmCampaign,
utmTerm: utm.utmTerm,
utmContent: utm.utmContent,
gclid: utm.gclid,
referrerUrl: utm.referrerUrl,
});
clearStoredUtmParams();
} catch {
// 失败先不清,等下次登录再试
}
};
void sync();
}, [data?.user?.id]);
return null;
}
挂载位置: src/app/[locale]/providers.tsx
import { UtmAttribution } from '@/analytics/utm-attribution';
// 放在 Providers 里,跟 OneTap / SessionRefresh 一起挂
<UtmAttribution />
步骤 4:邮箱注册时直接带 UTM(可选)
文件: src/components/auth/register-form.tsx
只有完成了“步骤 1(additionalFields)”才生效;否则字段会被忽略。
import { getStoredUtmParams } from '@/hooks/use-utm-capture';
const utm = getStoredUtmParams();
await authClient.signUp.email({
email: values.email,
password: values.password,
name: values.name,
callbackURL: callbackUrl,
utmSource: utm?.utmSource,
utmMedium: utm?.utmMedium,
utmCampaign: utm?.utmCampaign,
utmTerm: utm?.utmTerm,
utmContent: utm?.utmContent,
gclid: utm?.gclid,
referrerUrl: utm?.referrerUrl,
});
步骤 5:OAuth / One Tap(可选补丁)
如果你已经用 UtmAttribution 全局同步,这一步可以不写。
如果你想更早落库,可以在 post-oauth-client.tsx / one-tap.tsx 登录成功后手动 authClient.updateUser(...)。
步骤 6:更新 Admin 页面统计
文件: src/actions/get-admin-ads.ts
添加按 UTM 来源分组的统计:
// 按来源分组统计
const signUpsBySource = await db
.select({
source: user.utmSource,
count: count(user.id).as('count'),
})
.from(user)
.where(
and(
gte(user.createdAt, effectiveStartDate),
lte(user.createdAt, effectiveEndDate)
)
)
.groupBy(user.utmSource);
然后在 /admin/ads 页面展示"广告用户 vs 自然用户"的对比。
10.5 避坑指南
| 坑 | 原因 | 解决方案 |
|---|---|---|
| UTM 参数丢失 | 用户跳转多个页面后才注册 | 首次访问就存 localStorage,30天有效 |
| 覆盖问题 | 用户先从广告来,又从自然搜索来 | 首次触达归因:只存第一次的参数 |
| OAuth/One Tap 漏存 | 登录流程不走 register-form | 用 UtmAttribution 登录后同步,或在回调里 updateUser |
| 隐私合规 | GDPR 要求告知用户数据收集 | 隐私政策里说明会收集来源信息 |
| localStorage 禁用 | 部分用户禁用了 localStorage | 降级处理:不存就算了,不影响核心功能 |
| additionalFields 忘加 | Better Auth 不认识字段 | 在 src/lib/auth.ts 的 user.additionalFields 里声明 |
| 数据库迁移 | 生产环境加字段要小心 | 新字段都是可空的,不影响现有数据 |
10.6 替代方案(不改数据库)
如果不想改数据库 schema,可以用这些方式对照数据:
- Google Ads 后台转化报告:直接在 Ads 后台看转化数据,按广告系列/关键词筛选
- GA4 归因报告:GA4 的"转化路径"报告可以看到用户从哪来
- 手动对照:投广告期间的注册数 - 不投广告期间的日均注册数 ≈ 广告带来的注册
大白话: Google Ads 后台 + GA4 已经能满足大部分归因需求,UTM 入库主要是为了在自己的后台看数据。
10.7 实现优先级建议
| 阶段 | 内容 | 复杂度 |
|---|---|---|
| 现在 | 用 Google Ads/GA4 后台看归因数据 | 零代码 |
| MVP | 只加 utmSource 字段,区分"广告/自然" |
低 |
| 完整版 | 全套 UTM 字段 + Admin 可视化 | 中 |
11. 总结
| 功能 | 作用 | 状态 |
|---|---|---|
| gtag 转化追踪 | Google Ads 知道有转化 | ✅ 已实现 |
| OAuth 注册追踪 | Google/GitHub 登录也打 sign_up | ✅ 已实现 |
| /admin/ads 页面 | 自己后台看整体转化趋势 | ✅ 已实现 |
| One Tap 新用户 sign_up | One Tap 新用户也算注册 | ⏳ 待实现 |
| UTM 参数追踪 | 区分广告用户 vs 自然用户 | ⏳ 待实现 |
先用 Google Ads 后台 + GA4 看归因数据,UTM 入库可以作为后续优化项。
本文档为站内渲染。原始文件本地路径:saas/source/knowledge-world/Knowledge-World-项目-文档-ads-tracking-GOOGLE_ADS_TRACKING-930f5d.md(仅本地保留,不入库不部署)