“产品说要看用户行为数据,运营说要看页面停留时长,老板说要看转化漏斗……你说:好的,我先埋个点。”

结果埋了三个月,埋点散落在几百个文件里,漏埋、错埋、重复埋成了日常。每次需求变更,客户端还得跟着发版——这不是埋点,这是埋雷。

本文分享一套客户端全埋点方案的完整设计思路:从技术选型、事件协议设计、到客户端上报策略,解决"埋点多但有用的少"的问题。


一、先搞清楚:代码埋点、全埋点、可视化埋点有什么区别?

在动手之前,先把三种主流埋点方案的差异搞清楚:

方案 原理 优点 缺点 适用场景
代码埋点 开发者在关键位置手动调用 track() 接口 精准可控,自定义字段灵活 依赖发版,漏埋风险高,维护成本大 核心业务事件(支付、注册)
全埋点(无埋点) SDK 自动采集页面浏览、点击等基础行为 无需手动埋点,数据全面 数据量大,噪音多,无法采集业务语义 行为分析、路径还原
可视化埋点 运营/产品在页面上"圈选"控件定义事件 无需开发介入,上线快 覆盖有限,复杂交互难圈选 运营自助分析

实践建议:不要只选一种。最优解是全埋点 + 代码埋点混合——全埋点覆盖基础行为(页面浏览、前后台切换等),代码埋点补充业务语义(下单、收藏、分享等)。


二、技术选型:自研 vs 开源 vs 商业

1. 五种方案对比

方案 代表 优势 劣势 成本
开源方案 Snowplow、Matomo 数据自主、深度定制 部署复杂,需 Kafka+Spark 全家桶 人力成本高
商业 SaaS Mixpanel、Amplitude 分钟级接入,开箱即用 数据出境风险,长期成本高 按事件量收费
商业私有化 神策、GrowingIO 国产合规,提供实施服务 License 费用高,绑定供应商 年费制(数十万起)
云厂商集成 AWS Pinpoint、Azure App Center 无缝集成云生态 多云困难,分析能力弱 按资源计费
自研方案 Kafka + Flink + ClickHouse 100% 业务贴合,极致性能 研发周期长,需专业团队 一次性投入

2. 我们为什么选自研?

结合实际情况:

  • 已有基础设施:服务端已经有基于 SkyWalking LAL(Log Analysis Language)的日志采集链路——客户端上报的埋点数据先到后端接口,SkyWalking 通过 LAL 脚本从请求日志中提取埋点事件并转发到 Kafka,再由 Flink 消费写入 ClickHouse。整条链路都是现成的,差的只是客户端这一环
  • 数据安全:海外业务,用户数据不能出境到第三方 SaaS
  • 成本控制:团队规模不大,商业方案的年费不划算

一句话总结:不是自研牛,是刚好家里有矿(基础设施)


三、整体架构

┌─────────────┐     ┌─────────────────────────────────────────────────────────────┐
│  APP / H5   │     │                        服务端                                │
│             │     │                                                             │
│  埋点 SDK    │────▶│  采集接口  ──▶  SkyWalking LAL  ──▶  Kafka  ──▶  Flink  ──▶  │
│ ClickHouse  │     │                                                             │
│  (本地缓存)  │     │  /api/v1/track    (提取埋点事件)                               │
└─────────────┘     └─────────────────────────────────────────────────────────────┘
                                                                     │
                                                                     ▼
                                                                BI 看板 / 报表

SkyWalking LAL 在这里的角色:后端接口本身不做埋点逻辑,SkyWalking 通过 LAL 脚本从请求日志中自动提取埋点事件并转发到 Kafka,实现了零侵入的数据采集。

关键设计决策

决策点 选择 原因
采集协议 HTTP POST + JSON 简单通用,客户端无额外依赖
传输压缩 字段名缩写 + 批量上报 减少流量消耗,节省用户带宽
消息队列 Kafka 已有集群,天然支持高吞吐
实时计算 Flink 去重、清洗、关联用户维表
存储引擎 ClickHouse 列式存储,聚合查询快

四、事件协议设计

这是整个方案的核心——定义清楚"采集什么"和"数据长什么样"。

1. 事件类型总览

事件 ID 事件名 触发时机 超时丢弃阈值 典型用途
1 AppLaunch APP 冷启动 30 分钟 启动分析
3 AppExit APP 退出 1 天 会话时长
4 AppResume APP 进入前台 30 分钟 活跃分析
5 AppPause APP 进入后台 1 天 后台行为
7 AppCrash 崩溃发生 1 天 异常监控
10 PageEnter 页面进入 10 分钟 页面路径
11 PageLeave 页面离开 10 分钟 停留时长
20 Tap 控件点击 5 分钟 热力图
100 NetRequest 网络请求完成 10 分钟 接口性能

为什么要设超时丢弃阈值? 客户端可能因为网络问题积攒了大量过期事件。一条 3 天前的"页面浏览"事件,对实时分析毫无价值,还会干扰数据质量。不同事件的时效性不同,所以超时阈值也不同。

2. 公共字段(所有事件都有)

{
    "e": "PageEnter",          // 事件名
    "lts": 1712345700000,      // 本地时间戳(毫秒)
    "pg": "ProfilePage",       // 当前页面标识
    "pu": "https://...",       // 页面 URL(Web 端)
    "p": {                     // 扩展参数(各事件不同)
        "type": "",            // 子类型
        "sp": "tab_1"          // 子页面标识
    }
}

为什么字段名用缩写?

全称 缩写 节省
event e 4 字节/条
page pg 2 字节/条
timestamp lts 6 字节/条
params p 5 字节/条

看起来不多?假设日活 50 万用户,每人每天 200 个事件,一天就是 1 亿条。每条省 17 字节 = 每天省 1.7GB 传输流量。对移动端用户来说,流量就是钱。

3. 各事件的扩展参数

3.1 APP 启动(AppLaunch)
{
    "e": "AppLaunch",
    "p": {
        "sw": 1080,            // 屏幕宽
        "sh": 2400,            // 屏幕高
        "nw": "wifi",          // 网络类型:wifi / cellular / none
        "lt": "cold",          // 启动类型:cold-冷启动 / warm-温启动 / hot-热启动
        "sc": "push",          // 启动场景:icon-图标点击 / push-推送通知 / deep_link-深度链接
        "ov": "14",            // 系统版本
        "du": 850              // 冷启动耗时(毫秒)
    }
}
3.2 APP 退出(AppExit)
{
    "e": "AppExit",
    "pg": "ProfilePage",
    "p": {
        "et": "user_close",    // 退出类型:user_close-主动退出 / system_kill-系统回收 / crash-崩溃
        "du": 38000,           // 本次会话前台停留时长(毫秒)
        "nw": "wifi"
    }
}
3.3 APP 前后台切换

进入前台(AppResume)

{
    "e": "AppResume",
    "pg": "ProfilePage",
    "p": {
        "du": 45000,           // 后台停留时长(毫秒)
        "nw": "wifi",
        "fb": true             // 是否从后台恢复(true)/ 冷启动(false)
    }
}

进入后台(AppPause)

{
    "e": "AppPause",
    "pg": "ProfilePage",
    "p": {
        "du": 95000,           // 前台持续时长(毫秒)
        "nw": "cellular",
        "im": true             // 是否切换到其他应用 / 锁屏
    }
}
3.4 APP 崩溃(AppCrash)
{
    "e": "AppCrash",
    "pg": "ProfilePage",
    "p": {
        "ec": "NULL_POINTER",  // 错误类型
        "st": "...",           // 简化堆栈(脱敏后,限制 500 字符)
        "ov": "android 14",
        "dm": "SM-S9180",      // 设备型号
        "mu": "78%"            // 崩溃时内存占用
    }
}

堆栈信息必须脱敏 + 限长。曾经见过有团队把完整堆栈上报,单条事件 50KB,日活 30 万直接把 Kafka 打爆了。建议限制在 500 字符以内。

3.5 页面浏览(PageEnter / PageLeave)

进入页面

{
    "e": "PageEnter",
    "pg": "ProfilePage",
    "p": {
        "rf": "HomePage",      // 来源页面
        "ru": "https://...",   // 来源页面 URL(Web 端)
    }
}

离开页面

{
    "e": "PageLeave",
    "pg": "ProfilePage",
    "p": {
        "du": 15000            // 页面停留时长(毫秒)
    }
}

页面停留时长怎么算?

页面停留时长 = min(
    下一个 PageEnter 的时间,
    AppPause 的时间,
    会话超时时间
) - 当前 PageEnter 的时间

不能简单用 PageLeave.lts - PageEnter.lts,因为用户可能在页面上直接按 Home 键,这时候不会触发 PageLeave,而是触发 AppPause。

3.6 控件点击(Tap)
{
    "e": "Tap",
    "pg": "ProfilePage",
    "p": {
        "btn": "follow_btn",   // 按钮标识
        "btna": "关注",         // 按钮文案
        "gu": "https://...",   // 跳转链接
        "sx": 120,             // 点击坐标 X
        "sy": 340              // 点击坐标 Y
    }
}

五、上报接口设计

1. 接口定义

POST /api/v1/track?channel=xxx&uid=xxx
Content-Type: application/json

2. 请求体(批量上报)

{
    "events": [
        { "e": "AppLaunch", "lts": 1712345700000, "p": { "lt": "cold", "du": 850 } },
        { "e": "PageEnter", "lts": 1712345701000, "pg": "HomePage", "p": { "rf": "" } },
        { "e": "PageLeave", "lts": 1712345715000, "pg": "HomePage", "p": { "du": 14000 } }
    ]
}

3. 关键约束

  • 接口报错不能影响正常业务:客户端调用时需 try-catch 包裹,异常时静默失败,绝不弹窗、不阻塞 UI
  • URL 上的 channel(渠道)和 uid(用户 ID)作为公共参数,不放在每条事件体内,减少重复传输

六、客户端上报策略

事件采集后不能立即发送——频繁的网络请求会耗电、耗流量,还可能被系统限制。

1. 四种触发上报的时机

触发条件 说明
APP 进入后台 用户离开 APP 时立即上报,防止数据丢失
定时上报 每 60 秒上报一次
条数阈值 本地缓存超过 20 条时触发上报
账号切换 切换账号时立即上报,确保数据归属正确

2. 上报流程

事件产生 → 写入本地缓存(SQLite / 文件)
                    │
            ┌───────┴───────┐
            │ 满足上报条件?  │
            └───────┬───────┘
                    │ 是
                    ▼
         批量打包(最多 50 条/包)
                    │
                    ▼
        POST /api/v1/track?channel=xxx&uid=xxx
                    │
            ┌───────┴───────┐
            │   上报成功?    │
            └───┬───────┬───┘
              是│       │否
                ▼       ▼
          清除本地缓存  保留,等下次重试

3. 关键原则

  • 本地缓存有上限:最多缓存 1000 条,超出后丢弃最早的事件,避免占用用户存储
  • 失败重试有次数限制:同一批数据最多重试 3 次,避免死循环
  • 弱网优化:检测到无网络时暂停上报,网络恢复后自动续传

七、数据去重与清洗

客户端可能因为重试、网络延迟等原因重复上报。服务端需要去重:

去重规则事件名(e) + 本地时间戳(lts) + 页面标识(pg) 三个字段联合去重。

-- ClickHouse 去重示例(ReplacingMergeTree)
CREATE TABLE t_app_events (
    event_name String,
    local_ts   UInt64,
    page       String,
    user_id    UInt64,
    params     String,
    server_ts  DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(server_ts)
ORDER BY (event_name, local_ts, page, user_id);

ReplacingMergeTree 引擎,相同排序键的数据在后台 Merge 时自动保留最新的一条。


八、页面映射表

为了减少传输数据量,页面标识用短字符串而非完整路径。服务端维护一张映射表:

页面标识 页面名称 备注
HomePage 首页
SearchPage 搜索页
ProfilePage 个人资料页
DetailPage 详情页
ChatPage 聊天页
SettingPage 设置页
__Other__ 其他未标识页 兜底,params 中用 pl 字段传实际页面
__Background__ 后台 不是页面,表示 APP 退到后台

新增页面时只需在映射表加一行,客户端和服务端同步即可,不需要改代码。


九、经验总结

1. 踩过的坑

教训
崩溃事件的堆栈太长,单条 50KB 限制堆栈长度 500 字符,上报前做脱敏和截断
时间戳用服务端时间,但客户端时区不一致 改用客户端本地时间戳 lts,服务端只做参考
全埋点采集了所有点击事件,数据量爆炸 点击事件改为"可选采集",只采集打了标记的关键按钮
用户在飞行模式下操作,事件丢失 增加本地持久化缓存,网络恢复后补传

2. 设计原则

  • 采集 ≠ 分析:不要在客户端做复杂计算(比如算留存率),客户端只负责"忠实记录",分析交给数仓
  • 字段宁多勿少:公共字段尽量全,缺了后面想加只能等发版。但扩展字段放 params 里,灵活可控
  • 先跑通再优化:第一版不用追求完美,先把核心事件(启动、退出、页面浏览)跑通,再逐步加点击、崩溃等
  • 文档先行:事件协议文档是客户端和数仓的"合同",改协议等于改合同,要走评审流程

埋点这事,说到底就是"在正确的时间,采集正确的数据,送到正确的地方"。

听起来简单,但能做到不漏采、不多采、不影响业务的团队,真没几个。希望这篇文章能帮你少走一些弯路。


参考资料

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐