1. 先说清楚:hindsight 到底在解决什么问题
1.1 当“事发当时”无法重现,就需要“事后复盘”
做线上系统的兄弟应该都有过这种经历:用户截图过来说“页面一直转圈,啥也没反应”,你问了一堆问题,得到的回复往往是“就点了两下,然后就这样了”。你转头去翻服务端日志,看到一堆接口调用记录,却不知道用户到底点了哪个按钮、先填了什么再删了什么、页面是不是卡在某个异步请求上。最要命的是,这些问题大概率没法在本地复现,等你跑到线上看的时候,一切又恢复了正常。
hindsight 这个项目,就是冲着这个痛点去的。它的核心思路特别直白:把用户在页面上的关键操作、浏览器运行状态、网络请求结果,全部按时间顺序录下来。等到出了问题,不是靠猜,而是像看监控录像一样,把用户当时一步步的操作轨迹拉出来重看。这种“事发之后回看全过程”的能力,在英语里就是 hindsight——后见之明。
我最早做这个项目,其实是被线上事故逼出来的。当时我们的客服团队每天要接几十个“页面异常”工单,技术同学真正能定位到根因的可能不到四成,大部分时间都耗在反复确认“你当时点了什么”“有没有报错提示”这种低效沟通上。做一个行为回放系统的想法在脑子里转了几个月,直到某次支付链路事故让我彻底下决心动手。回放系统上线之后,很多原本要拉群讨论半小时的问题,打开回放看几十秒就能锁定方向。
这个系统适合谁?前端团队、后端排查线上问题的同学、以及负责用户支持和质量保障的工程师,都能从中受益。它不追求实时监控或者智能告警,专注做一件事:让“事后还原现场”这件事从“做不到”变成“能做到”。这听起来似乎没那么高大上,但在真实排障场景里,它省下的时间成本是实打实的。
1.2 项目命名的含义与定位
很多人第一次听到 hindsight 这个名字,第一反应是“哦,事后诸葛亮”。确实,它解决的就是事后复盘的问题。但我起这个名,想表达的更多是一种调试思路的转变:与其试图在事故发生前预测所有异常,不如先把“低成本还原现场”的能力建好,让每一次事故都有完整的现场资料可供追溯。
hindsight 的定位很明确,它不是商业产品里那种复杂的用户行为分析平台,不搞漏斗分析、留存曲线、人群分群那套东西。它更像一个轻量级的“现场记录仪”,专注做采集、存储、回放这三件事。你可以理解成每个用户会话都有了一台 DVR(数字录像机),平时不需要有人盯着看,出事的时候随时能翻出录像来查。
这个定位决定了我在设计时反复提醒自己两件事:第一,不过度设计,用最简单的方案解决最核心的问题;第二,明确边界,哪些事情不做和做哪些事情同样重要。比如我不做实时异常告警,因为那是监控系统该干的活;我也不做复杂的多维分析报表,因为那是数据分析平台的地盘。hindsight 只回答一个问题:当时发生了什么,按什么顺序发生的。
2. 整体设计:从一个“录像机”的视角拆解系统
2.1 核心需求拆解
把“行为回放”这个概念放大来看,整个系统可以分成采集、存储、回放三个环节,对应录像机的摄像头、录像带和播放器。
采集端要解决的是“录什么、怎么录、怎么传”。不是所有事件都要录,录得太细费流量费存储,录得太粗又起不到还原现场的作用。我最终的采集清单是:点击、输入、滚动、路由切换、接口请求结果、JS 报错、页面性能指标、以及一些关键的状态变化,比如页面隐藏、恢复、断网重连。
存储端要解决的是“录完放哪儿、怎么高效查出来”。回放场景下的典型查询模式是:给定一个 session_id,把这个会话在某个时间段内的所有事件按顺序全部取出来。这种查询是典型的高吞吐顺序扫描,和日志检索那种“关键词命中后看上下文”的模式不太一样。
回放端要解决的是“怎么把事件流变成可视化的操作过程”。这一步的难点不在于简单的播放,而在于如何用事件流重建页面状态,让回放看起来像真实的操作过程,而不是干巴巴的事件列表。
三者的关系是单向依赖的:采集决定数据质量,存储决定查询效率,回放决定最终体验。任何一环设计不到位,整个系统都会拉胯。比如你采集了事件,但时间戳乱序,回放端再怎么处理也是错的;你存储设计得再好,采集端漏掉了关键事件,回放也就是个残缺录像。
2.2 技术选型背后的取舍
技术选型上,我没有一上来就上全家桶,而是围绕“够用、可控、能跑起来”这三个原则来定。
存储这块,我对比过 Elasticsearch 和 ClickHouse。ES 的倒排索引擅长关键词检索,但回放场景根本不需要搜什么关键词,就是“按时间范围拉出一整段事件流”,这时候 ES 的性能反而不如 ClickHouse。ClickHouse 的列式存储和 MergeTree 引擎,对“按主键筛选 + 按时间排序扫描”这种查询模式特别友好,实测千万级事件量下,拉取一个会话的完整事件流基本在百毫秒级别。Redis 在这里的定位是会话热数据的暂存区,因为最近几小时的会话可能随时被打开查看,没必要都落到 ClickHouse,等会话结束一段时间后再异步落盘归档。
埋点 SDK 则直接用原生 JavaScript 写,不引第三方框架。核心考量的点是依赖越少,侵入性越小,被业务方接受的门槛就越低。上报通道优先用navigator.sendBeacon,因为这个接口在页面卸载时也能可靠发数据,不像fetch/XMLHttpRequest在 unload 阶段容易被浏览器取消。
回放渲染我做了两版方案对比:第一版是 DOM 重建,也就是存储事件时把当时的 DOM 状态序列化,播放时重建出相似结构;第二版是 Canvas 绘制,做一个虚拟的简化页面,只绘制出关键变化。最终我选了 DOM 重建为主、Canvas 辅助的方式。原因是 DOM 重建可以复用一个轻量虚拟 DOM 库做 diff,书写成本低,而且大部分前端同学维护起来没有心理负担。Canvas 方案更适合做“像素级还原”,但实现成本高、数据量也大,对一个内部排障工具来说性价比不高。
2.3 数据模型与核心事件设计
数据结构是整个系统的地基,事件定义得好不好,直接决定后续所有环节的复杂度。我设计的事件结构是统一的信封格式:
{ "session_id": "sess_20240517_ab3f8c", "event_id": "evt_8f3a9b2c1e", "ts": 1715903345120, "server_ts": 1715903345240, "type": "click", "seq": 42, "data": { "selector": "button[data-role='submit']", "page_url": "/order/confirm", "x": 328, "y": 412 } }核心字段的作用我简单拆一下:session_id是会话的唯一标识,所有事件通过它聚合;ts是客户端记录的事件发生时间,用于回放排序;server_ts是服务端接收时间,用来修正客户端时间偏移;seq是递增序号,同一样毫秒内多个事件的次序靠它区分;type是事件类型,data是不同类型对应的扩展字段。
事件类型我总共定义了 7 种:click(点击)、input(输入)、scroll(滚动)、route_change(路由切换)、request(网络请求)、error(JS 异常)、performance(性能关键指标)。为什么不采集mousemove?因为滚动和点击已经能还原大部分操作路径,鼠标轨迹的数据量太大,回放时信息密度又低。出于性能考虑,我宁可不收,也不增加噪音。
会话的切分规则也花了一些心思。连续无操作超过 30 分钟就记为新会话;跨零点也切分,方便按天统计;另外遇到路由从业务域跳到登录域再跳回来,也会强制切一下,因为这种场景多半是登录态失效,单独看更有价值。
数据量估算是这样的:一个活跃用户平均每分钟产生约 30 个有效事件,一天活跃 2 小时,就是 3600 个事件。假设日活跃用户 1 万,一天大概 3600 万条事件。ClickHouse 对这种量级完全没压力,但前提是不要存储无用的冗余字段,比如整个 DOM 快照,这种体积大户能省则省。
3. 核心技术实现:从埋点到回放的完整链路
3.1 埋点 SDK 的细节设计
采集端的 SDK 是用户无感的,但实现时有几个容易被忽略的细节。
第一个细节是“什么时候采集”。比如点击事件,直接在click监听器里记录数据是最简单的方式,但如果你还关心用户点击后页面是否白屏,就需要额外监听error事件。这里有个坑:单页应用的路由切换不是整页刷新,popstate和hashchange都要监听到,而且要手动给路由变化补一个自定义事件,否则回放时会发现页面 URL 变了,但操作序列里没有跳转动作。
第二个细节是“怎么采集才能不掉数据”。页面 crash 或者用户直接关标签页的瞬间,常规的fetch上报大概率丢数据。sendBeacon就是为这种场景设计的,它不阻塞页面卸载,而且数据量上限相对宽裕。深度封装一下,SDK 内部在上报时会做降级链:sendBeacon不可用就用fetch的keepalive: true,再不行就退回到Image打点。
第三个细节是大量事件产生时的性能保护。输入框在快速打字时,每敲一个字符都算input事件,如果无脑上报,一分钟就能产生几百条。我在这里做了节流:相同 DOM 节点上的input事件,在 1 秒内最多上报一次,只记录输入后的值和光标位置。滚动事件通过requestIdleCallback调度,保证不影响当前页面的渲染主线程。
还有一个不能回避的问题:隐私和敏感信息。密码输入框我从设计上就不采集内容,只记录“该输入框有过输入行为”。手机号、身份证这类字段,SDK 端做了一次正则匹配脱敏,如果命中敏感格式,直接替换为[REDACTED]再发送。这块你要是留了明文,后面合规审计会是巨大的隐患,千万别图省事。
3.2 会话与会话超长处理
session_id 的生成比较简单,前端首次加载时生成 UUID,存到sessionStorage里。注意用sessionStorage而不是localStorage,因为前者在用户关掉浏览器标签页时会自动清除,天然符合“一次会话”的定义。用户在同一标签页里刷新页面,session 数据保留;新开标签页打开网站,就是一个新会话。
实际的线上场景里,有些用户会把页面挂一整天不关,期间不断操作、停下、再操作。这种会话如果整体回放,事件量可能上万,对查询和渲染都有压力。我采用了分段回放机制:回放时不像视频播放器那样只能从头播到尾,而是支持按时间段切片,比如只看“用户最后操作的 5 分钟”。
长会话还有一个优化点:连续鼠标移动和滚动,如果保持原始事件粒度会非常占用存储。我在 SDK 端直接做合并,同一个scroll滚动事件,如果 3 秒内连续触发且滚动方向一致,就把中间过程压缩成一条“起始位置 + 结束位置”,回放时插值还原。这个压缩策略能把事件量砍掉 40% 到 60%,信息量几乎没有损失。
会话结束时机的判断不光靠前端,服务端也会兜底。如果一个 session_id 超过 40 分钟没有收到任何新事件,就视为会话结束,触发异步归档任务,把 Redis 里的热数据刷到 ClickHouse。这个双保险是为了防止前端在最后的 beacon 请求里标记了“end”但是因为网络问题丢失。
3.3 回放服务与时间线构建
回放的第一步是把原始事件变成一条有序的“动作时间线”。这个环节看着简单,实际坑特别多。
最大的坑是时间戳乱序。用户电脑的本地时钟很可能不准,可能快了几分钟、慢了几分钟,这种情况下直接用ts字段排序,回放出来的操作顺序可能是错的。我的处理方式是:服务端收到事件时记录server_ts,首条事件到达后,计算delta = server_ts - ts,后面的事件都认为客户端偏移量基本不变,用ts + delta作为回放时间。这个方案在大多数场景下够用,代价是首条事件的延迟会对整个会话产生整体位移,但对相对顺序没有影响。
还有一类特殊情况:用户在弱网环境下操作,事件确实产生了,但迟迟没传到服务器。这种情况下如果严格按照server_ts排序,就会看到事件“突然冒出来”,完全不符合真实时间线。我的兜底原则是:优先按客户端ts排序,server_ts只做偏移修正参考。实测下来,客户端事件产生顺序在绝大多数浏览器里是可靠的。
回放接口返回的数据结构是一个定长数组:
{ "session_id": "sess_20240517_ab3f8c", "start_ts": 1715903340000, "end_ts": 1715903400000, "events": [ { "t": 500, "type": "route_change", "data": { "to": "/order/confirm" } }, { "t": 1230, "type": "click", "data": { "selector": "button[data-role='submit']" } }, { "t": 1250, "type": "request", "data": { "url": "/api/order/submit", "status": "pending" } } ] }t是相对回放起始时间的毫秒偏移,回放端只需要一个全局定时器,每次 tick 时把所有t小于当前偏移的事件批量应用到页面上。
前端回放渲染,我用的方案是:维护一个虚拟 DOM 映射,事件流经过回放引擎后对模型做增删改,再通过 diff 更新真实 DOM。这个方案的好处是回放过程可以随时暂停、拖动进度条、跳到某个事件,用户体验很接近视频播放器。
3.4 一个具体的排障案例
理论说多了,不如看一个真实案例。某天傍晚,客服群里反馈“订单确认页一直 loading,用户付不了款”。反馈的用户不止一个,看起来像是出现了较大范围的问题。
以前遇到这种情况,常规操作是先拉服务端网关日志,看订单接口的 5xx 率和平均响应时间,然后猜测是不是数据库慢查询。但这一天,我直接打开 hind sight 面板,输入其中一个用户提供的会话 ID,拖动到报障前 1 分钟开始回放。
回放画面里看到的情况是这样的:用户进入订单确认页,页面渲染正常,然后点击“提交订单”按钮,按钮进入 loading 状态。过了大约 3 秒,接口请求返回了一个 network error,按钮恢复成可点击状态。用户又点了一次,结果同上。前后一共点了四次,时间跨度不到 10 秒。紧接着用户切到客服页面,输入了一段反馈。
这个过程里,我在事件流中看到了请求的参数和返回状态码,发现接口请求根本没有到达后端网关——问题出在中间的 CDN 层。再结合同一个 CDN 节点其他用户的会话,确认是节点异常导致请求超时。整个定位过程前后不到 5 分钟。放到以前,光是在各种日志系统之间切换、对时间线,都够忙活一阵了。
这个故事不是要说明 hindsight 有多智能,它本质上就是一个“把现场录下来”的工具。但就是这种看似朴素的能力,让排查思路从“根据各种旁路证据推断”变成了“直接看现场”。值班同学至少省了一半的心力。
4. 常见问题与排查经验实录
4.1 “回放时事件对不上:点击位置偏移、DOM 状态不对”
这个问题在回放系统里几乎是必然会遇到的。用户回放时看到点击坐标和实际按钮位置对不上、或者页面的某个模块在回放里怎么都找不到。
仔细排查后原因其实不复杂:页面里大量使用懒加载,某些图片占了位,让下方元素往下推移;字体加载前后宽高不同,导致布局错位。这些因素叠加起来,用“事件发生时的 viewport 坐标”去还原点击位置,自然就对不上。
我的处理方案是:放弃过分精确的像素级还原,回放时把点击事件突出展示为一个高亮圈,并显示命中的图层面板(基于埋点时记录的selector字段)。如果selector命中了真实 DOM 节点,就展示当前节点信息;如果没命中,就提示“此处可能涉及异步加载,组件未命中”。这样做虽然产生了少量误报,但大幅降低了排查成本,也避免了因为布局漂移产生的误导。
4.2 “数据量太大,回放卡顿”
现象是长会话回放时,播放到后期越来越卡,拖动进度条要等很久。核心原因是内部把完整事件流一次性拉回了前端,渲染引擎在 1 秒内应用几百个 DOM 变更,浏览器撑不住。
优化做了三步。第一是加载策略改为分段拉取,回放时只拉当前视口前后 2 分钟的事件,其他按需加载;第二是虚拟列表,右侧事件列表和 DOM 变更区域只渲染可见部分,不可见的直接跳过;第三是对事件做降采样,比如连续同类型的scroll事件,回放时合并成一个位移动作,减少无意义的动画中间帧。
做完整套优化后,一个 2 小时会话的回放,首屏加载时间从 8 秒降到了 1 秒左右,播放过程基本稳定在 60 帧。代价是回放细节稍微糙了一点点,但对定位问题来说完全够用。
4.3 “时间戳不准,顺序错乱”
事件顺序错乱是最影响回放观感的问题。用户回放时会看到数据在回放结束后才“补录”进来,或者某个点击发生的时间点和页面状态对不上。
根源有两个:一是客户端时钟漂移,二是网络延迟导致事件延迟上报。我的解决方法是前面提到的server_ts首次偏移校正,但这里有个细节值得提醒:不要对每一条事件都做动态校正,否则如果某段时间网络出现抖动,事件的时间顺序会被反复挤来挤去,回放时反而更怪。
实操时,我采用的是“锚点校正”策略:每个会话固定以首个事件的server_ts - ts作为整体偏移量,中途不修正。只有在 session 超过 30 分钟且中间出现了 10 分钟以上的上报断档时,才重新计算偏移量,并在事件流中标记一个time_correction事件。这样既保证了时间线的整体稳定性,又能处理极端情况。
4.4 加密和隐私合规怎么处理
这块是回放系统绕不开的敏感话题。在发布前,我和团队把隐私相关的问题列了一张清单:哪些数据必须采集、哪些不能碰、怎么让用户知情、怎么设置保留期限。
最终定的规则是:密码框内容、支付 CVV、短信验证码这类绝对不采集,采集端把这些选择器的type过滤掉。普通输入框内容可以采集,但 SDK 端做字段级脱敏,用正则匹配手机号、身份证号等格式后替换成[REDACTED]。在页面上有敏感输入的聚焦状态下,即使数据脱敏,也需要注意不要让日志打印明文内容。
系统权限方面做了分级:普通值班人员只能看“脱敏后的操作概览”,只有具备排障权限的工程师才能看完整回放细节。所有回放数据保留期设置为 90 天自动清除。这块虽然平时不太能体现价值,但一旦有审计需求,拿得出手的设计就是给团队省事的资本。
5. 一些想留给后来者的经验总结
做 hindsight 这个项目,我最大的体会是:定位比功能重要得多。一开始我特别想把回放做得“酷”,上了很多花哨的视觉还原效果,差点让项目变成灾区。后来想明白了一个本质问题——回放系统不是给普通用户看热闹的,是给工程师定位问题用的。工程师需要的是“快速看到关键字、快速跳过无意义时间、快速定位到异常点”,这些基础能力扎实做好,回放系统就已经可以有明显的排障价值了。
第二个体会是埋点数据质量远比后端架构重要。有一次我们回放时发现有一批会话的点击事件全部丢失了,排查了很久才发现是新版 SDK 的代码压缩配置把事件类型字段改名了,导致服务端解析失败。这种问题如果不做校验,数据进到 ClickHouse 里才报警,影响面就大了。所以我现在在 SDK 端会加一条“上报前校验事件结构”的断言逻辑,开发模式下打印警告、生产模式下静默丢弃并计数。
另外一个特别实用的技巧:事件的data字段设计上,尽量不要放整个对象,而是按固定 key 拍平。这样不仅存储省空间,在 ClickHouse 里按字段过滤也快。如果一开始设计成嵌套 JSON,后面改造的成本会让你非常痛苦。
如果后续还想扩展,我可以看到两个方向:第一是把错误的堆栈信息和事件流关联,回放时如果某个事件触发了 JS 异常,自动把 stack trace 对齐展示;第二是基于 LLM 做事件流的自动摘要,比如回放结束后自动生成一段“用户点击了提交按钮,接口超时两次,随后转向客服页面”的文字说明。这样连打开回放画面这一步都能省了,直接看结论。不过这些都是后话,先把基础的数据链路打扎实,才是正事。