index

当浏览器追踪不再可靠:用 sGTM 搭建服务端追踪

Shopify 商店切到 Headless 后浏览器追踪层层掐断,用 Cloudflare Workers 和 sGTM 搭了一条覆盖五个平台的服务端管道——以及过程中撞上的每一堵没有文档的墙。

Shopify Headless 迁移之后,浏览器端追踪大面积失效。我们用 sGTM(Server-side Google Tag Manager)搭了一套服务端追踪来补,覆盖 GA4、Google Ads、Meta、Reddit、X 五个平台。这是完整复盘——不只是最终方案长什么样,更多是过程中踩过的坑。总觉得下一步应该很简单,结果又撞上一堵没有文档的墙。如果你也在评估 sGTM,或者正在跟服务端追踪搏斗,这些弯路或许能帮你省点时间。

先看最终架构全貌,只需要注意三层:入口(Worker)、路由中心(sGTM)、兜底(Webhook + Firestore)。后面逐层拆:

如果你赶时间,直接跳:


浏览器追踪为什么不够用了

如果你平时不做广告投放,可以先把问题理解成一句话:用户从广告点进来之后,在站内浏览、加购、结账、支付完成这些行为,都要回传给广告和分析平台。平台拿到这些信号,才能做归因、优化投放、动态再营销。举个最直接的例子:用户从 Google Ads 点进来买了东西,如果这笔转化没回传,Ads 就不知道这条广告有效,后续投放优化全是盲的。

回传有两条路径。客户端追踪是在用户浏览器里跑一段 JavaScript(也就是各平台的 Pixel),由浏览器直接把事件发给广告平台。服务端追踪是由你自己的服务器调用各平台的 Conversions API(CAPI)来上报。客户端的优势是能拿到完整的浏览器上下文——Cookie、Click ID、User-Agent——但容易被广告拦截器干掉,也受浏览器隐私策略(Safari ITP、Chrome 第三方 Cookie 限制)削弱。服务端不怕拦截,但天然缺少浏览器侧的归因信号,需要额外做桥接。成熟的方案通常两条路径同时跑,互相补位。

理解了这个前提,再看我们踩过的三个阶段就清楚了——前三次尝试全在客户端打转,每次都撞上同一堵墙。

第一阶段:靠 Shopify 自带的追踪 App。 最早 Shopify Storefront 和 Gatsby Headless 商店共存,追踪完全依赖各广告平台在 Shopify 后台提供的官方 App。Gatsby 端没有做任何埋点——流量进来了,但转化数据只有走 Storefront 的那部分能报上去。

第二阶段:Gatsby 接入客户端 Pixel。 意识到 Gatsby 端是追踪盲区后,开始在 Gatsby 上做埋点:根据 Shopify App 后端的事件 ID,在 Gatsby 前端注入对应的报送代码。问题是这套埋点松散,各平台各写各的,view_itemadd_to_cart 的电商事件数据格式不统一,维护成本直线上升。

第三阶段:接入 Web GTM + Google Tag Gateway。 想用 GTM 统一管理所有追踪代码,同时开启 Google Tag Gateway(服务端代理)来绕过广告拦截。但 Gateway 只能代理 Google 自家的请求——Meta、Reddit、X 的 Pixel 仍然走第三方域名,照样被拦。等于只解决了五分之二的问题。

三次尝试分别解决了覆盖、统一、绕拦截,但都没有解决母问题:浏览器信号天然不稳定。 广告拦截器会干掉请求(uBlock Origin 默认规则下纯客户端数据丢失率 15-30%)、Safari ITP 会压缩 Cookie 寿命、Shopify Checkout 把 Custom Pixel 关进 sandbox iframe 导致归因 Cookie 读不到。客户端追踪的天花板就在这里——转服务端。


架构概览与核心决策

决定转服务端之后,第一个问题是:怎么转?

我们同时在四个广告平台投放、用 GA4 做分析,每个平台的 CAPI 格式和去重机制都不一样。Shopify 各广告平台的官方 App 本身就自带 server-side tracking(通过 Webhook 上报转化),我们在第一阶段用过,知道服务端路径是可行的——但各 App 各自为政,数据格式和电商数据对不上。如果每个平台各搞一套 Server-side 方案:

所以要补的不是某一个平台的 Pixel,而是一条统一的服务端管道:前面先把请求活着送进来,中间把电商事件数据和归因信号整理干净,后面再按各平台要求分发出去。

为什么用 sGTM 做路由中心

我们本来就在用 Web GTM,sGTM 是最顺手的延伸:Client 端事件可以直接打到 Server Container,不用从零重做整套事件模型。另外 GTM 的 UI 也方便团队里非技术的人维护 Tag 配置,不用每次改追踪都找开发。

数据流上,Pixel 是主路径——浏览器上下文完整,归因质量最高。Shopify 的 orders/paid Webhook 天然没有浏览器信号,只做兜底:Pixel 没发成功时,Webhook 补上。两条路径怎么不重复,在 Firestore 去重那节展开。

每一层各管各的——Worker 挂了不影响 sGTM 处理 Webhook,Firestore 出问题也不阻塞 Pixel 主路径——坏了不会连锁反应。下面逐层拆,先从 Worker 开始:请求得先活着到达 sGTM,后面的一切才有意义。


Worker:骗过广告拦截器

Worker 本质上是一个反向代理,坐在浏览器和 Cloud Run(sGTM)之间。目标只有一个:让追踪请求看起来不像追踪请求。

广告拦截器靠模式匹配来拦请求。uBlock Origin 默认加载的 EasyList 主要拦广告相关路径(/pagead/googlesyndication.com 等),EasyPrivacy 则专门拦追踪(/g/collect/gtag/jsgoogle-analytics.comcx=c&gtm 这类)。排查时两份规则都要对着看:哪些路径会被匹配、哪些查询参数组合会触发拦截,然后逐条设计别名绕过。比如 EasyPrivacy 里有一条 ||googletagmanager.com/gtag/js,会直接拦截 gtag 的加载请求;另一条 &cx=c&gtm= 则匹配 GA4 数据收集请求里的参数组合。这两条不处理,GA4 数据收集和 Google Ads 转化测量就都废了。

Worker 具体做四件事:

路径改写。/g/collect/gtag/js 这些 Google 的固定路径替换成无意义的缩写。实际有 20+ 条路径别名,覆盖 GA4 数据收集、Google Ads 转化测量、Consent Mode、gtag destination 等。漏掉任何一条,对应的功能就会被拦。

参数改写。 EasyPrivacy 会匹配 cx=c&gtm 这类查询参数。Worker 在转发前替换成别名,sGTM 侧再还原。

JS 运行时替换。 GTM 的 JavaScript 里硬编码了 www.googletagmanager.com 和各种 Google 路径。Worker 在返回 JS 之前,把域名、路径、参数名全部替换成我们自己的别名。

// 路径 + 参数改写(精简版)
const PATH_MAP = {
  '/main.js': '/gtm.js?id=GTM-XXXXXXX',
  '/d/c': '/g/collect',
  '/a/s': '/gtag/js',
  '/x/pa': '/pagead/viewthroughconversion'
}

function rewriteJS(body) {
  return body
    .replace(/\/g\/collect/g, '/d/c')
    .replace(/\/gtag\/js/g, '/a/s')
    .replace(/cx=c&gtm/g, '_cx=c&_g')
    .replace(/www\.googletagmanager\.com/g, 'tracking.example.com')
}

Header 透传。 Worker 还负责把浏览器的上下文信息传到 sGTM,不然服务端拿到的数据是残的:

  • CF-Connecting-IPX-Forwarded-For(真实用户 IP)
  • Sec-CH-UA-* Client Hints(设备和浏览器信息)
  • Cookie 头双向透传(_ga_fbp 等第一方 Cookie)
  • X-Country-Code(用户国家,Enricher 用来匹配 Merchant Center feed 语言)

有一点要注意:不是所有路径都能代理给 sGTM。Google Ads 的一些辅助链路(转化测量、CCM(Consent Mode 相关的 Cookie 管理)、再营销像素等)不是 sGTM 容器的入站端点,硬转给 sGTM 会直接 400。Worker 需要把这些路径识别出来,回源到 Google 的原始上游(googleadservices.comgooglesyndication.com 等)。另外 Worker 也做了 CORS 来源校验,非白名单域名的请求直接 403。

实测在 uBlock Origin 默认规则集(EasyList + EasyPrivacy)下,所有改写后的追踪请求均正常通过。从 GA4 数据看,Safari 和 Chrome 的漏斗转化率基本持平(Safari 0.72% vs Chrome 0.56%),说明 Safari ITP 对归因的影响也被服务端路径成功桥接了。


sGTM:一个事件进来,五个平台出去

sGTM 跑在 GCP Cloud Run 上。GA4 Client 接收事件后,先经过 Items JSON Enricher 做数据标准化,再由各平台的 Tag 分别消费同一份事件数据——GA4 Tag 回传分析事件,Google Ads Tag 上报转化和购物车明细,Meta / Reddit / X 的 Tag 各自调用对应的 CAPI。每个 Tag 独立触发,互不依赖。

但事件能正确分发的前提是,电商数据本身得先对齐。

电商事件数据标准化

具体有多乱:Shopify 官方追踪 App 生成的 item_id 是一串内部编号,和 Merchant Center feed 的 offer ID 完全不匹配——Google Ads 动态再营销因此拉不到正确的商品图片和价格。GA4 那边问题不同:同一个商品因为本地化出现英文名和中文名两条记录,Ecommerce 报告里一个产品拆成好几行,分析数据全是碎的。

Meta CAPI 也要 content_id 和 Catalog 一致才能做 DPA。所以电商事件数据必须做两层标准化:

第一层在前端 Pixel。 Custom Pixel 里有一个 PRODUCT_NAMES 字典,把 Shopify SKU 映射成标准英文商品名,保证 dataLayer 里的 item_name 从源头一致,不会出现中文名、日文名混用。

第二层在 sGTM 的 Items JSON Enricher。items_json 字符串解析回 items 数组,校验 item_id = Shopify SKU = Merchant Center offer ID,补全 aw_feed_countryaw_feed_language 给 Google Ads 动态再营销用。

这样不管事件最终发给谁,消费的都是同一份干净的电商事件数据。

各平台 CAPI 对接

平台事件范围去重
GA4漏斗事件(Purchase 仍走浏览器主路径)event_id
Google AdsPurchase + 购物车明细transaction_id
MetaPurchase + 漏斗事件48h event_id
Reddit全漏斗 + download_clickevent_id
X全漏斗 + download_clickevent_id

实际效果用 Google Ads 的数据最直观:sGTM 服务端 Purchase Tag 的观测转化率(observed conversion rate)稳定在 95–100%,同期 GA4 导入的 Purchase 转化几乎全靠 Google 建模补齐(观测率 0–8%)。换句话说,服务端路径把转化数据直接送到了 Google Ads,不再需要平台猜。

踩过的坑:

  • Reddit CAPI 的 eventType 必须大写 "Purchase"。小写 purchase 被静默忽略,不报错,也没有日志。
  • X CAPI 现在是直接在 sGTM 模板里做 OAuth 1.0a HMAC-SHA256 签名,不再走外部代理。凭据或签名参数配错,请求会直接失败。
  • Google Ads 购买转化要上报购物车明细,必须在 Tag 上开 enableProductReporting 并挂 Items JSON Enricher 作为 setupTag。

写 Tag 时撞上的沙箱限制

Items JSON Enricher 需要解析 JSON、遍历数组、做类型转换——听起来很基础,但在我们这套 sGTM 模板里每一步都踩过坑:

  • 标准 JS 全局并不完整。 没有 parseFloat 这类现成工具时,要改成 require('makeNumber')。另外我们在线上真的撞到过 String.prototype.charCodeAt() 兼容性问题,最后改成了 trim()charAt() + indexOf() 这种写法。
  • addEventData 现在已经是主链路的一部分。 当前 live 的 Items JSON Enricher 就在 Tag 模板里用 addEventData 回填 itemsecommerce_itemsaw_feed_countryaw_feed_language。所以这里真正的坑不是「不能用」,而是 setupTag 的执行顺序和事件字段来源要对齐。

这些零碎限制叠在一起,一个本来半天能写完的 Enricher 拖了两天。后面沙箱那一节还有更离谱的。


浏览器信号桥接:Cookie 和 Click ID

sGTM 解决了「事件发给谁」的问题,但平台做归因还要拿到浏览器侧的 Cookie 和 Click ID。麻烦在于,购买漏斗不是一条连续链路,而是在 Gatsby → Shopify Checkout 的跳转处被切成了两段:前半段在我们自己的域名上,后半段在 Shopify 的结账域名上。

Gatsby 站点上的 GTM JS 负责前半段:pageviewview_itemadd_to_cart 这些站内行为,都是浏览器直接打到 Worker,再进 sGTM。到了 Shopify Checkout,Gatsby 这条链断掉,后半段才切到 Custom Pixel。Cookie Bridge 和 Click ID Bridge,本质上都是在给这两段链路补桥。

Custom Pixel 接管了 checkout 漏斗的五个关键节点(从 checkout_startedcheckout_completed),每个事件都带完整的 ecommerce 数据和 user_data(email、phone、address),后者是 Google Enhanced Conversions 和 Meta Advanced Matching 的基础。

sendBeacon + keepalive: true 现在主要用在 Cookie Bridge 的 /store-cookies POST 上,而不是 Purchase 主事件本身。Purchase 主路径仍然是 dataLayer → GTM → Worker → sGTM;sendBeacon 负责的是把 _fbp_fbctwclidrdt_cid 这些上下文尽量在离页前补送到服务端。

Shopify Custom Pixel 跑在 sandbox 环境里,Pixel 代码跟主页面隔离,直接读不到各平台的归因 Cookie——Meta 的 _fbp/_fbc、X 的 twclid、Reddit 的 rdt_cid 全拿不到。没有这些,CAPI 上报的事件就没法跟浏览器侧的点击关联,归因全断。

好在 Shopify 提供了 browser.cookie.get() 异步 API。Pixel 用它把这些 Cookie 逐个捞出来,通过两个通道往外发:

通道 A:Cookie 值塞进 meta_cookies 字段,跟着 Purchase 事件走 GTM → Worker → sGTM。这是正常路径。

通道 B:单独写一份到 Firestore,Pixel 没发成功时 Webhook 再从这里把 Cookie 捞回来。

Click ID Bridge:从 URL 到购物车

Cookie Bridge 解决了 Pixel → Webhook 的 Cookie 传递。但 Click ID 还有另一层问题:用户从广告点进来时,URL 里的 gclidrdt_cidtwclid 需要先被主站拿住,再穿过 checkout 边界进入 sGTM。

现在的做法是:CF Worker 提供改写后的 GTM JS,Web GTM 在浏览器里读取 URL 参数、写入第一方 Cookie,再作为 GA4 event params 随事件透传到 sGTM。整条链路不依赖 Shopify 侧的任何 API。

早期尝试过另一条路:通过 Shopify 的 /cart/update.js 把 Click ID 写进 note_attributes,让 Webhook 携带到 sGTM Webhook Client。但 onekey.so 是 Headless 前端,/cart/update.js 环境不稳定,这条路现在只作为 Fallback 保留在 Webhook Client 里。

不同平台的桥接也不完全对称:twclid 有 URL / Cookie / localStorage 三级兜底;rdt_cid 主打 dataLayer / 第一方 Cookie / URL Fallback;Google Ads 则更多依赖 _gcl_* Cookie 和服务端恢复逻辑。


Firestore:去重

为什么会有两条路径?Shopify 的 orders/paid Webhook 从服务器发出来,天然没有浏览器上下文——没有 _fbp/_fbc(Meta 归因靠这个)、没有 gclid(Google Ads 归因靠这个),连 User-Agent 和 IP 都不是用户真实的。Cookie Bridge 能补回一部分,但毕竟是二手数据,Pixel 路径才是归因质量最高的。所以架构上 Pixel 是主路径,Webhook 只做兜底——Pixel 没发成功时,Webhook 补上。

但 Shopify 不管 Pixel 有没有成功,Webhook 都会发。Custom Pixel 即时上报,Webhook 大概 40 秒后到达——两条路径同时跑,不做去重就是每笔订单报两次。这个时间差决定了去重的设计:Pixel 有足够时间先写入 Firestore 标记,Webhook 到达时再查这个标记就能判断是否需要补发。

各平台自己都有 event_id 去重(Meta 是 48 小时窗口,其他平台也有类似机制),但我不想依赖它。重复上报会干扰平台侧的归因计算和事件质量评分——问题不在于配额,而在于数据干净。

所以在 sGTM 层用 Firestore 做前置去重:Pixel 路径成功上报后,往 Firestore 写 { reported: true }。Webhook 到达时先查这条记录,有就跳过,没有时再走当前启用的 Fallback Tags。

从 GA4 数据看,上线以来 Pixel 主路径的 purchase 事件占比超过 99%。Pixel 成功率足够高,Webhook 兜底实际触发的频率很低。

按当前 live 配置,这套去重主要保障 Google Ads、Meta、Reddit、X 这些 Purchase Fallback。GA4 Native Purchase - Shopify Webhook 目前处于 paused,所以 GA4 购买事件仍以浏览器主路径为准,不是靠 Webhook 补齐。

一笔订单的完整旅程

前面按层拆了 Worker、sGTM、信号桥接、Firestore,这里把它们拼回一条完整链路。支付完成后两条路径独立触发:Pixel 即时经 Worker 进入 sGTM,Enricher 标准化后分发到五个平台,同时往 Firestore 写入去重标记;大约 40 秒后 Shopify Webhook 到达 sGTM,查 Firestore——已标记则跳过,未标记则走 Google Ads、Meta、Reddit、X 的兜底 Tag(GA4 Purchase 始终走 Pixel 主路径,不走 Webhook 兜底)。

Firestore 里存了什么

两个 Collection:

sgtm_cookies/{cart_token} — 当前 live 的 Cookie Store Client 是 30 天保留期

字段类型说明
fbpstringMeta _fbp
fbcstringMeta _fbc
twclidstringX click ID
rdt_cidstringReddit click ID
expires_atnumber当前模板写入的是毫秒时间戳

sgtm_purchases/{transaction_id} — 当前模板里也是 90 天保留期

字段类型说明
reportedbooleantrue = 已上报
sourcestringpixel | webhook
timestamp_msnumber写入时刻
expires_atnumber当前模板写入的是毫秒时间戳

另外还有一份 sgtm_purchase_context/{transaction_id},用于在 Webhook 兜底路径里恢复浏览器侧的会话上下文。


sGTM 沙箱:两个最伤开发体验的限制

sGTM 沙箱是一个阉割版 JavaScript 运行时:语法看起来是 JS,但标准 API 缺失、权限在运行时静默执行、出错没有任何反馈。前面在 Enricher 那节已经碰到了一些(没有 parseFloatcharCodeAt() 不可用),这里集中讲两个更严重的——严重不在于功能缺失,而在于你连”出了什么问题”都无法知道。

try/catch 会让调试变成黑箱。 直觉上,写 try/catch 是为了防错。但在 sGTM 沙箱里,如果 try 块内的代码触发了沙箱级别的中止(比如调用了未声明权限的 API),catch 不会捕获这个错误——整个 Tag 直接停止执行,不报错、不进 catch、不留日志。加了 try/catch 反而更难定位问题,因为你连”代码在哪一行停的”这个信息都丢了。我们现在的做法是完全不用 try/catch,改用最土的方式:逐行插 logToConsole,发版本,看日志,缩小问题范围。

权限缺失不报错,Tag 直接静默中止。 这个是在 Template #33 上线后踩的真实事故:新增了 _twclid_rdt_cidrdt_cid 三个 Cookie 的 getCookieValues 调用,但漏了在模板的 permissions 里声明 get_cookies 对应的 Cookie 名。结果不是报错,不是 console 警告,而是整个 Tag 在运行时静默中止——线上所有经过这个 Tag 的事件全部丢失,直到有人注意到数据断流才排查出来。

教训很明确:代码改动和权限改动必须一起部署。 否则最坏的情况不是「报错」,而是「安静地不工作」。

这两个问题叠在一起,定义了 sGTM 的调试体验:问题不在于功能少——少了可以绕——而在于你永远不知道代码停在了哪一行,也不知道它为什么停的。


如果让我重来

数据稳了,但如果重新选一次技术方案,我大概率不会再用 sGTM。

当时选它看起来很合理:团队已经在用 Web GTM,事件模型不用重做,而且 sGTM 生态里有大量现成的服务端 Tag 模板——GA4、Google Ads 官方维护的,Meta、Reddit、X 社区贡献的——看上去拼一拼就能跑。但实际部署下来,无论是官方还是社区的模板,多多少少都有问题:参数类型不对、权限声明不全、边界情况没处理。每个模板都要拆开看源码、改一轮才能用,「开箱即用」的预期完全落空。

调试体验更像打地鼠——修好一个权限问题,又冒出一个静默失败;补上一个 API 缺失,又撞上一个类型不兼容。每轮都是发版本、看日志、猜哪一行停的,非常耗时间和精力。如果重来,我的选择标准会变:调试反馈 > 生态兼容 > UI 便利

具体来说:一个 Cloudflare Worker,一个 D1 数据库,按各平台 CAPI 文档直接对接。一份事件数据进来,自己写映射逻辑分发到各平台——本质上跟 sGTM 做的事一模一样,但部署秒级生效,console.log 就能调试,不用发新版本去线上看日志。跟 sGTM + GCP 那套比,开发体验完全不在一个量级。

当时没有认真考虑这条路,是因为觉得「自己写 CAPI 对接」工作量太大。但回过头看,sGTM 模板省下的开发量,全还在调试和绕沙箱上。净算下来不一定省了。

那 sGTM 适合谁?

团队已经重度依赖 Web GTM,有大量 Tag 和触发器配置,sGTM 作为自然延伸才是合理的。

但如果你没有 GTM 的历史包袱,或者像我们一样需要对接多个非 Google 平台、还带自定义去重逻辑,直接写代码比在沙箱里跟权限系统斗智斗勇快得多

Google Tag Manager 这类平台最初的设计假设,是「用 GUI 降低使用门槛,让非技术人员也能配置服务端追踪」。但当问题高度定制、又高度依赖调试反馈时——对接多个平台 CAPI、补浏览器上下文、做自定义去重——那些为了「不用写代码」而引入的抽象层和沙箱限制,反而变成了阻碍。尤其是当 Coding Agent 已经能直接读文档、生成对接代码、跑测试、修 bug 的时候,GUI 工具原本的优势还剩多少,值得重新想想。

评论

正在加载评论...