知识库首页 知识库-世界 GOOGLE_ADS_TRACKING.md

GOOGLE ADS TRACKING

本地来源:Knowledge/World/项目/文档/ads-tracking/GOOGLE_ADS_TRACKING.md

SeedVR2 谷歌广告转化追踪(项目版大白话)

先说结论:这个项目已经“装好收款机”了(gtag 脚本 + GA4 页面浏览),我们只差在关键动作上“按一下按钮”把转化打出去。下面是按本项目的真实代码位置写的 SOP。


TL;DR(先看这一段就够用)

  1. Google Ads 后台先建转化:至少建 PurchaseSignUpBeginCheckout
  2. 代码里的 4 个打点已接上:注册成功、登录成功、点“去付款”、支付成功(没有就按清单补)。
  3. 支付成功打点要带金额/币种/订单号:项目里已返回,别漏。
  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.tsxonSuccesslogin

邮箱注册: src/components/auth/register-form.tsxonSuccesssign_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 拿到 amountyearlyTotal(单位是分)。 - 积分包: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.tsuser.additionalFields 里声明
数据库迁移 生产环境加字段要小心 新字段都是可空的,不影响现有数据

10.6 替代方案(不改数据库)

如果不想改数据库 schema,可以用这些方式对照数据:

  1. Google Ads 后台转化报告:直接在 Ads 后台看转化数据,按广告系列/关键词筛选
  2. GA4 归因报告:GA4 的"转化路径"报告可以看到用户从哪来
  3. 手动对照:投广告期间的注册数 - 不投广告期间的日均注册数 ≈ 广告带来的注册

大白话: 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(仅本地保留,不入库不部署)