本文由雲+社區發表作者:elson
現狀分析
在定位外網問題時,最怕的是遇到無法復現或者是偶現的問題,我們無法在用戶的設備上通過抓包、打斷點或日誌來分析問題,只能靠僅有的頁面截圖和用戶的片面描述作爲線索。此時,也只能結合“猜想法”和“排除法”進行分析定位,排查了半天也很有可能沒有結果,最後只能回覆“可能是緩存或者app的原因,請清下緩存或者重新安裝app試試”。
導致我們定位外網問題時效率低下,主要還是因爲缺乏定位線索;其次由於用戶並不瞭解技術層面的前因後果,他們可能會忽略掉一些關鍵信息,或者提供了帶有誤導性的線索。
常見的外網問題成因
從筆者實際上所遇到的外網問題進行歸類,主要有以下成因:
- 後臺數據返回異常,或部分數據爲空;
- 針對邊界情況,頁面未做相對應的容錯措施,導致頁面報錯;
- 用戶的網絡環境、APP版本問題;
- 通過上一級入口進入頁面時,漏傳部分參數;
- 與用戶特定的操作步驟相關所引發。
針對頁面JS報錯,我們已有腳本異常上報監控機制,業界也不乏相關的優秀開源產品,如sentry。但往往很多情況下的用戶反饋以及外網異常並不是腳本異常引起的,此時無法觸發異常上報。因此針對這部分場景,我們需要有另一套機制進行上報監控,輔助我們定位分析。
用戶的行爲軌跡的重要性
從上面的問題成因可以得出,如果我們能採集到並結合以下幾方面數據,那外網異常的定位自然會事半功倍:
- 頁面的運行環境
- 頁面所加載的數據
- 頁面JS報錯信息
- 用戶的操作日誌(時間線)
我們可以通過時間戳將以上數據串聯起來,形成時間線。這樣一來,頁面的運行環境、頁面中每個動作相關的數據、動作之間先後關係就會一目瞭然,就像一部案發現場的錄像。因此這裏強調“軌跡”的重要性,能夠把散亂的數據串聯起來,這對我們分析定位問題非常有幫助。
基於上面的分析結論,我們搭建了一套用戶行爲軌跡追蹤系統,大致工作流程爲:在頁面中加載JS SDK用於數據記錄和上報,服務器接收並處理數據,再以接口的方式提供數據給內部查詢系統,支持通過用戶UIN以及頁面地址進行查詢。
下面我們從報什麼、怎麼報、服務器如何處理數據、數據怎樣展示四方面具體談一下整體的設計思路。
設計思路
報什麼:確定上報內容及協議
根據上面的分析,我們已經初步得出了需要上報的數據內容。
上報的內容最終需要落地到查詢系統中,因此首先需要確定怎樣查詢。我們將用戶在某頁面的單次訪問作爲基本查詢單位,假設某用戶訪問了3次A頁面,那麼在查詢平臺中就可以查出3條記錄,每條記錄可以包含多條不同類型的子記錄,它們共用“基礎信息”。大致的數據結構如下:
const log = {
baseInfo: {},
childLogs: [{...}, {...}, ...]
};
基礎信息
baseInfo
中記錄的是頁面的運行環境,可以稱爲“基礎信息”,具體包括以下字段:
字段名 | 描述 | 可選參數 |
---|---|---|
FtraceId | 某次頁面訪問的唯一標識(自動生成) | |
Fua | navigator.userAgent | |
FclientType | 客戶端類型 | 0:未知 1:qqmusic 2:weixin 3:mqq |
Fos | 系統 | 0:未知 1:ios 2:android |
Furl | 頁面地址 navigator.userAgent | |
Frefer | 頁面上級入口 document.referrer | |
FloginType | 帳號類型 | 0:wx 1:qq |
Fuin | 用戶帳號 |
childLogs
中保存所有子記錄,以下是子記錄的公用字段以及三種不同類型。
子記錄公共字段
每條子記錄需要記錄時間戳、標識上報類型,因此需要定義以下的公共字段:
字段名 | 描述 | 可選參數/格式 | 備註 |
---|---|---|---|
Flogtype | 上報類型 | 0: ajax通信 1:用戶操作 2:報錯異常 | |
FtimeStamp | 時間戳 | 串聯不同類型的上報記錄,形成軌跡 | |
Forder | 數字順序 | Number | 當前記錄在整條軌跡中的自增序號 |
Forder
的作用在於當兩條記錄的 FtimeStamp
值相同時,作爲輔助的排序依據。
子記錄類型1:ajax通信
記錄頁面中所有ajax通信的數據,方便排查異常是否與後臺數據有關。
字段名 | 描述 | 可選參數 |
---|---|---|
FajaxSendTime | ajax請求發起時間點 | |
FajaxReceiveTime | ajax數據接收到時間點 | |
FajaxMethod | ajax請求類型 | 0:get 1:post |
FajaxParam | ajax請求參數 | |
FajaxUrl | ajax請求鏈接 | |
FajaxReceiveData | ajax請求到的數據 | |
FajaxHttpCode | http返回碼(200, 404) | |
FajaxStateCode | 後臺返回的業務相關code碼 |
子記錄類型2:用戶操作行爲
記錄打點數據以及用戶點擊操作的DOM上的數據
字段名 | 描述 | 可選參數/格式 |
---|---|---|
FtraceContent | 自定義上報內容 | String |
FdomPath | 操作目標DOM的xpath | |
Fattr | 目標DOM的所有data-attr屬性及其值 | {att1: '123', att2: '234'} |
子記錄類型3:報錯異常
記錄JS報錯信息以及我們手動拋出的異常信息
字段名 | 描述 | 可選參數/格式 | 備註 |
---|---|---|---|
FerrorType | 錯誤類型 | 0:原生錯誤 1:手動拋出的異常 | |
FerrorStack | 錯誤堆棧 | 僅原生錯誤報 | |
FerrorFilename | 出錯文件 | ||
FerrorLineNo | 出錯行 | ||
FerrorColNo | 出錯列位置 | ||
FerrorMessage | 錯誤描述 | 原生錯誤的errmsg或者開發自定義 |
怎麼報:SDK的數據採集及上報策略
上述的數據需要通過頁面加載SDK進行採集,那麼怎樣採集,如何上報?
數據採集方式
從業務場景以及常見的外網問題考慮,我們只關注帶有登錄態的場景。對於未登錄或獲取不到登錄態的場景,SDK不做任何數據採集和上報。
( 1 ) 基礎信息
FtraceId
可以直接搜 uuid 的生成算法,用戶每進入頁面時自動生成一個,後續採集的子記錄共用此 ID。
其他字段則可以從 cookie 或者原生 API 中獲取,這裏不再贅述。
( 2 ) ajax 通信數據
這裏用到了一個開源組件 Ajax-hook ,源碼很簡練,GZIP 後只有 639 字節。主要原理是通過代理 XMLHttpRequest
以及相關實例屬性和方法,提供各個階段的鉤子函數。
hookAjax({
open: this.handleOpen,
onreadystatechange: this.handleStage
});
一次 ajax 通信包含 open
,send
,readyStateChange
等階段,因此需要在不同階段的鉤子函數中採集從請求發起到接收到請求響應的各方面數據。
具體來說
- 在
open
中可以採集:請求發起時間點、請求方法、請求參數等。需要注意過濾掉無用的請求,如數據採集後的上報請求。 -
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);
};
}
- 在
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採集及上報策略)、數據如何處理、數據怎樣展示,四個方面介紹瞭如何搭建用戶行爲軌跡追蹤系統。目前只是個初級版本,有很多地方需要繼續完善和改進。有了追蹤用戶軌跡數據,能夠從很大程度上有效靈活地應對用戶反饋和外網異常,從而也很好地提升了我們的工作效率。
參考
此文已由騰訊雲+社區在各渠道發佈
獲取更多新鮮技術乾貨,可以關注我們騰訊雲技術社區-雲加社區官方號及知乎機構號