如何优雅地定位外网问题——动手搭建用户行为轨迹追踪系统

现状分析

在定位外网问题时,最怕的是遇到无法复现或者是偶现的问题,我们无法在用户的设备上通过抓包、打断点或日志来分析问题,只能靠仅有的页面截图和用户的片面描述作为线索。此时,也只能结合“猜想法”和“排除法”进行分析定位,排查了半天也很有可能没有结果,最后只能回复“可能是缓存或者app的原因,请清下缓存或者重新安装app试试”。

导致我们定位外网问题时效率低下,主要还是因为缺乏定位线索;其次由于用户并不了解技术层面的前因后果,他们可能会忽略掉一些关键信息,或者提供了带有误导性的线索。

常见的外网问题成因

从笔者实际上所遇到的外网问题进行归类,主要有以下成因:

  1. 后台数据返回异常,或部分数据为空;
  2. 针对边界情况,页面未做相对应的容错措施,导致页面报错;
  3. 用户的网络环境、APP版本问题;
  4. 通过上一级入口进入页面时,漏传部分参数;
  5. 与用户特定的操作步骤相关所引发。

针对页面JS报错,我们已有脚本异常上报监控机制,业界也不乏相关的优秀开源产品,如sentry。但往往很多情况下的用户反馈以及外网异常并不是脚本异常引起的,此时无法触发异常上报。因此针对这部分场景,我们需要有另一套机制进行上报监控,辅助我们定位分析。

用户的行为轨迹的重要性

从上面的问题成因可以得出,如果我们能采集到并结合以下几方面数据,那外网异常的定位自然会事半功倍:

  • 页面的运行环境
  • 页面所加载的数据
  • 页面JS报错信息
  • 用户的操作日志(时间线)

我们可以通过时间戳将以上数据串联起来,形成时间线。这样一来,页面的运行环境、页面中每个动作相关的数据、动作之间先后关系就会一目了然,就像一部案发现场的录像。因此这里强调“轨迹”的重要性,能够把散乱的数据串联起来,这对我们分析定位问题非常有帮助。

基于上面的分析结论,我们搭建了一套用户行为轨迹追踪系统,大致工作流程为:在页面中加载JS SDK用于数据记录和上报,服务器接收并处理数据,再以接口的方式提供数据给内部查询系统,支持通过用户UIN以及页面地址进行查询。

下面我们从报什么、怎么报、服务器如何处理数据、数据怎样展示四方面具体谈一下整体的设计思路。

设计思路

报什么:确定上报内容及协议

根据上面的分析,我们已经初步得出了需要上报的数据内容。

上报的内容最终需要落地到查询系统中,因此首先需要确定怎样查询。我们将用户在某页面的单次访问作为基本查询单位,假设某用户访问了3次A页面,那么在查询平台中就可以查出3条记录,每条记录可以包含多条不同类型的子记录,它们共用“基础信息”。大致的数据结构如下:

const log = {    baseInfo: {},    childLogs: [{...}, {...}, ...]};

基础信息

baseInfo中记录的是页面的运行环境,可以称为“基础信息”,具体包括以下字段:

子记录公共字段childLogs中保存所有子记录,以下是子记录的公用字段以及三种不同类型。

每条子记录需要记录时间戳、标识上报类型,因此需要定义以下的公共字段:

Forder的作用在于当两条记录的 FtimeStamp 值相同时,作为辅助的排序依据。

子记录类型1:ajax通信

记录页面中所有ajax通信的数据,方便排查异常是否与后台数据有关。

子记录类型2:用户操作行为

记录打点数据以及用户点击操作的DOM上的数据

子记录类型3:报错异常

记录JS报错信息以及我们手动抛出的异常信息

怎么报:SDK的数据采集及上报策略

上述的数据需要通过页面加载SDK进行采集,那么怎样采集,如何上报?

数据采集方式

从业务场景以及常见的外网问题考虑,我们只关注带有登录态的场景。对于未登录或获取不到登录态的场景,SDK不做任何数据采集和上报。

( 1 ) 基础信息

FtraceId可以直接搜 uuid 的生成算法,用户每进入页面时自动生成一个,后续采集的子记录共用此 ID。

其他字段则可以从 cookie 或者原生 API 中获取,这里不再赘述。

( 2 ) ajax 通信数据

这里用到了一个开源组件 Ajax-hook ,源码很简练,GZIP 后只有 639 字节。主要原理是通过代理 XMLHttpRequest 以及相关实例属性和方法,提供各个阶段的钩子函数。

hookAjax({    open: this.handleOpen,    onreadystatechange: this.handleStage});

一次 ajax 通信包含 opensendreadyStateChange 等阶段,因此需要在不同阶段的钩子函数中采集从请求发起到接收到请求响应的各方面数据。

具体来说

  1. 在 open 中可以采集:请求发起时间点、请求方法、请求参数等。需要注意过滤掉无用的请求,如数据采集后的上报请求。
  2. send 中主要用于采集 POST 请求的请求参数。
handleOpen(arg, xhr) {        const urlPath = arg[1] && arg[1].split('?');        xhr.urlPath = urlPath[0];
        // 过滤掉上报请求        if (/stat\.y\.qq\.com/.test(urlPath[0])) {            return;        }
        curAjaxFields = $.extend({}, ajaxFields, {            FtimeStamp: getNowDate(),            FajaxSendTime: getNowDate(),            FajaxMethod: arg[0] ? methodMap[arg[0].toUpperCase()] : '',            FajaxUrl: urlPath[0],            FajaxParam: urlPath[1],            Forder: logger.order++        });
        xhr.curAjaxFields = curAjaxFields;
        const _oriSend = xhr.send.bind(xhr);        xhr.send = function(body) {            // POST请求 获取请求体中的参数            if (body) {                curAjaxFields.FajaxParam = body;            }            _oriSend && _oriSend(body);        };    }
  1. 在 readyStateChange 中,当 xhr.readyState 为 2(HEADERS_RECEIVED) 或 4(DONE) 时,分别采集 FajaxReceiveTime 和 响应数据相关数据。这里需要注意的,为了把前期从 open 和 send 中采集到的数据传递下来,我们将数据对象挂载在当前 xhr 对象上: xhr.curAjaxFields=curAjaxFields; 。
handleStage({ xhr }) {        // 过滤掉上报请求        if (/stat\.y\.qq\.com/.test(xhr.urlPath)) {            return;        }        switch (+xhr.readyState) {            case 2: // HEADERS_RECEIVED                $.extend(xhr.curAjaxFields, {                    FajaxReceiveTime: getNowDate(),                    FajaxHttpCode: xhr.status                });                break;
            case 4: // DONE                const xhrResponse = xhr.response || xhr.responseText;                let jsonRes;
                try {                    // 如果回包不是json格式的话会报错                    jsonRes = xhrResponse ? JSON.parse(xhrResponse) : '';                    ...                } catch (e) {                    console.error(e);                }
                $.extend(xhr.curAjaxFields, {                    FajaxReceiveData: xhrResponse,                    FajaxStateCode: jsonRes ? getStateCode(jsonRes).join(',') : ''                });
                break;        } }

( 3 ) 用户操作行为

通过事件代理,在 document 上监听指定类 .js_qm_tracer 的事件回调。在回调中通过 event.path 取到当前 dom 的路径;通过 event.currentTarget.attributes 取到当前 dom 上的所有属性。

同时还提供 API 实现自行上报 action.report(data)

$(document).on('click', '.js_qm_trace', e => {    const target = e.currentTarget;    // 时间戳    let FtimeStamp = getNowDate();
    // Dom的xpath    let FdomPath = _getDomPath(e.path);
    // dom的所有data-attr属性以及值    let Fattr,        FtraceContent = null;    if (target.hasAttributes()) {        let processedData = _processAttrMap(target.attributes);        Fattr = processedData.Fattr;        FtraceContent = processedData.FtraceContent;    }    ......});

上报策略

上面的数据,如果我们记录一条就上报一条,这无疑是给自己制造DDOS攻击。此外,我们的初衷在于帮助排查外网问题,因此在我们需要用的时候再报上来就行了。所以需要引入本地缓存和用户白名单机制,采集完先在本地缓存起来,需要的时候再根据用户白名单“捞取”。

本地缓存机制我们选用的是 IndexedDB,它容量大( 500M ),异步读写的特性保证其不会对页面渲染产生阻塞,此外还支持建立自定义索引,易于检索,更适合管理采集到的数据。

用户白名单机制则是通过一个后台服务,SDK初始化后都会先查询当前用户和页面URL是否均在白名单中,是的话则将之前缓存的数据进行上报,而之后的用户行为操作也会直接上报,不再先缓存。

但如果遇到JS错误报错,属于紧急情况,这时则不再遵循“缓存优先”,而是直接上报错误信息以及当前采集到的其他数据。

上报策略流程图:

白名单机制流程图:

获取到白名单用户的数据需要用户再次访问页面,一方面从性能和开发成本考虑,另一方面反馈外网问题的用户很大概率是会再次访问当前页面的。只需要再次进入页面,无需额外操作,这样对用户来说也没有沉重的操作成本和沟通成本,简单易操作。

数据处理:服务器对数据的处理策略

( 1 ) 首先,数据上报请求经过 nginx 服务器后,会生成 access.log。

http {    log_format trace '$request_body';
    server {        location /trace/ {           client_body_buffer_size 1000m;           client_max_body_size 1000m;           proxy_pass http://127.0.0.1:6699/env;           access_log /data/qmtrace/log/access.log trace;       }    }
    server {        listen 6699;        location /env/ {            client_max_body_size 1000m;            alias /data/qmtrace/;       }    }}

使用 nginx 日志进行记录,主要是因为 nginx 优异的性能,能抗住高并发;此外其接入和维护成本也较低。

这里在处理 POST 请求的日志时,遇到一个坑。如果不经过 proxy_pass 转发一次的话,nginx 无法对 POST 请求产生日志记录。

此外需要注意的是缓冲区的大小, client_body_buffer_size 默认只有 8K 或 16K,如果实际请求体大小超过了它,那就会被忽略,无法产生日志记录。

( 2 ) 通过 crontab 每五分钟定期处理一次 access.log

access.log 移动到相应的以年月日小时命名的目录下,生成 access_${minute}.log

移走 access.log 之后,此时需要执行以下命令,发送通知给 nginx,收到通知后会重新生成新的 access.log

kill -USR1 `cat ${nginx_pid}`

最后用node脚本,对 access_${minute}.log 进行解析处理后入库。

数据展示:搭建查询平台

采集到的数据,在内部查询平台通过用户 UIN 进行检索,同时支持输入特定的页面 URL,进一步聚焦检索结果。

在之前我们提到,将用户在某页面的单次访问作为基本查询单位,假设某用户访问了3次A页面,那么在左侧就会检索出3条记录(每条记录都有唯一标识 FtraceId )。

为了查询平台的性能考虑,每次查询只会返回左侧的记录列表以及第一条记录的详细信息。点击其他记录再根据 FtraceId 进行异步查询。

右侧展示的是某条记录的详细信息,通过时间线的形式将用户在某次页面访问期间的行为轨迹直观地展示出来。通过客观且直观的用户轨迹数据,我们就可以更高效更有针对性地分析定位外网问题。

总结

我们通过报什么(上报内容及协议)、怎么报(SDK采集及上报策略)、数据如何处理、数据怎样展示,四个方面介绍了如何搭建用户行为轨迹追踪系统。目前只是个初级版本,有很多地方需要继续完善和改进。有了追踪用户轨迹数据,能够从很大程度上有效灵活地应对用户反馈和外网异常,从而也很好地提升了我们的工作效率。

参考

1.前端异常监控解决方案研究

https://cdc.tencent.com/2018/09/13/frontend-exception-monitor-research/

2.监控平台前端SDK开发实践

https://juejin.im/post/598850c9f265da3e3b66c49e

3.浏览器数据库 IndexedDB 入门教程

http://www.ruanyifeng.com/blog/2018/07/indexeddb.html

4.Ajax-hook 原理解析

https://www.jianshu.com/p/7337ac624b8e

原文发布于微信公众号 - QQ音乐前端团队(QQMusicFE)

原文发表时间:2019-03-17

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

编辑于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券