今年,騰訊雲控制檯的事故數量隨着用戶量、業務規模和業務複雜度的增長而增長。盤點這些事故之後,我們發現當中有不少問題是用戶“幫”我們發現的。
主動發現並收斂問題,是團隊的主要目標。在這個大前提下,團隊利用 ELK 對 Node 層日誌系統進行升級改造,並搭建了前端監控系統。兩個方案讓我們撥開迷霧,對自己的業務狀態有了一個清晰的瞭解。
Node 層接入 ELK 框架,團隊之前有過分享,在此不再贅述。本文主要分享一下利用 ELK 搭建前端監控系統的一些經驗。
0x00 背景
騰訊雲的控制檯並不是由單個業務團隊完成的,而是採取平臺 + 業務接入的方式。
- 平臺方負責控制檯框架的維護,以及支持各個業務團隊的業務接入
- 業務團隊進行控制檯前端頁面的開發
之前,平臺在監控投入上疲軟,導致兩個尷尬:
- 測試沒有覆蓋的問題,無法先於用戶發現,非常被動
- 業務出現問題反饋到平臺方定位時,平臺缺少足夠的數據和工具來快速排查
部分業務團隊也自己採取了一些監控手段。然而,不是所有業務團隊都有這個時間和精力在監控上進行投入。平臺進行統一監控能力的建設,顯得刻不容緩。
0x01 目標
建設前端監控系統,我們希望:
- 業務方無感知接入,平臺上報前端的異常事件
- 提供工具給到業務方和一線進行問題定位
- 基於數據建設精確化告警,異常觸達平臺和業務方
- 能夠基於數據衡量問題的收斂效果,並評估業務健康度
0x02 模型
平臺方採取 CRAD 模型來建設監控體系,並且在實踐上取得了一定的成功經驗。
所謂 CRAD 模型,是指一個監控生命週期的四個環節:
- 事件採集 (Capture):採集多種事件的多個維度數據,爲後續問題分析提供數據基礎;
- 事件接收 (Receive):對採集到的事件進行消化和二次加工後,進入到 Elasticsearch 數據庫
- 事件分析 (Analysis):對 Elasticsearch 中的數據,通過各種預定義的工具進行分析
- 驅動優化 (Driven):基於事件分析的結果,驅動優化的方案並實施
事件採集 (Capture)
事件採集,最重要兩點:
- 定義好需要採集的事件類型
- 確定好事件所需要採集的維度
先從維度討論,我們爲所有事件都加入了下列的公共維度:
- 時間:用於統計事件基於時間的變化規律,可用於分析問題的事件分佈和衡量問題的收斂效果;
- 軌跡:用於跟蹤相同會話下的一連串事件,方便在定位問題時分析到上下文;
- 業務:用於區分事件所屬的業務,方便提供業務團隊使用的專項視圖,也可用於統計業務健康程度;
- 用戶:用於快速定位確定用戶所出現的問題,也可以用於分析問題發生的用戶分佈規律;
- 客戶端:用於分析問題發生的瀏覽器分佈,大多用於分析前端異常;
在事件定義上,我們在第一個監控版本定義了一連串的事件,包括:
優先級 | 事件 | 說明 |
---|---|---|
異常 | js-error | JS 運行發生異常 |
resource-error | JS/CSS 資源加載異常 | |
render-oops | 業務頁面渲染異常 | |
null-page-10s | 業務頁面白屏 10 秒以上 | |
promise-error | 捕獲到未處理的 Promise 異常 | |
告警 | login-show | 登錄框被調用 |
cgi-error | CGI 調用返回碼非零 | |
cgi-warn | CGI 調用響應時長超過 5s | |
error-log | 捕獲到 console.error 的輸出內容 | |
render-404 | 用戶訪問了不存在的頁面 | |
null-page-5s | 業務頁面白屏 5 秒以上 | |
tip-error | 使用頂部消息組件打印了錯誤消息 | |
信息 | startup | 控制檯應用啓動 |
load-view | 加載指定的視圖 | |
nav | 發生了路由導航 | |
logout | 登錄組件註銷方法被調用 |
這些事件中,我們會重點關注異常和告警,而信息將會輔助問題定位。
總結下來,定義的事件具備哪些維度,決定了你可以按照何種方式來聚類它們,從而發現其中規律;定義的事件類型,則決定了你可以擁有哪些信息。
事件接收 (Receive)
收集到的事件,我們並沒有直接加入 Elasticsearch 數據庫,而是進行了一系列的處理:
- 解析:將 HTTP 報文中的數據解析爲事件對象
- 構建:將事件對象中的數據二次加工,如 UA 解構、IP 信息查詢等
- 加白:把事件對象拿到所有的加白規則中運行,打上加白標記
- 合併:合併一定時間內具有相同特徵的事件,減少相同重複上報
在經過這些步驟的加工之後,事件纔會打印到本地日誌上,給 Beats 採集到 Elasticsearch 數據庫。
事件分析 (Analysis)
事件分析,我們要有手段,也要有工具。Kibana 已經提供了很強大的數據可視化能力,我們配置好視圖就可以解決大部分問題。
在我們的實踐中,建立了最重要的兩個視圖:
- 異常流水視圖
- 軌跡跟蹤視圖
作爲主要問題排查視圖,在設計上,可以注意:
- 將重點關注的字段、可以二次轉化的字段,加入到表格中,方便快速給事件定性和問題排查;
- 預定義過濾條件,可以在搜索事件的時候快速篩選;
- 對於可以二次跟進的字段,利用 Kibana 索引字段設置功能,添加超鏈接到二次視圖(如我們在異常流水中,個 d.lid 字段增加了到軌跡視圖的鏈接)
在事件採集階段,我們提到「軌跡」這個事件維度,此時可以派上用場。通過軌跡跟蹤視圖,我們可以清楚地查看到某個用戶在異常事件發生前後的相關事件,方便定位問題。
這兩個視圖,就是我們在事件分析的過程中主要使用到的工具。下一節,會有更多工具的介紹。
驅動優化 (Driven)
驅動優化是一個 CRUD 生命週期的最後一步,也是最直接作用的一步。在背景中,已經介紹過控制檯屬於平臺-業務對接模式。所以,業務的異常問題,不由平臺來收斂,而是平臺通過提供工具和服務給到業務方來定位。
想要業務方配合推動工作,就需要站在業務角度思考:
- 業務方爲什麼要來配合
- 業務方配合的成本如何
我們建立了日報視圖,該視圖可以把每個業務每天的異常數量、分類、排名看的清清楚楚。業務負責人看到這些數據,就會意識到業務對多少用戶產生了哪些影響。這樣,業務方就有了充足的理由來解決這個問題。並且,這些問題解決之後,他們也可以基於這個數據衡量效果,作爲自己業績的一部分,一舉兩得。
在以前,用戶反饋一個業務的問題,往往就是丟出一張截圖。實際上,用戶產生異常時的上下文信息,已經丟失了很多。此時,業務排查問題的難度是非常大的,復現就很困難。
平臺通過提供一系列的工具和服務來解決這個痛點,包括上述提到的數據分析視圖(異常流水、軌跡跟蹤),堆棧查看工具、事件加白機制等等。在必要的時候,我們還會上門服務,和業務一起定位問題。
關於堆棧查看工具,在文章後面的章節中有介紹,感興趣的讀者可以翻到後面。
在這個機制的推動下,在 9 月份我們收斂了 60% 的線上問題。
0x03 案例
通過 CRAD 模型來收斂問題,平臺也有自己的實踐,在這裏分享一個比較突出的案例。
Round 1 - 發現大量 resource-error
- Capture:監控系統上線後,就一直採集靜態資源加載失敗(
resource-error
)事件 - Receive:事件上報 ES 後
- Analysis:我們分析發現,這些事件數量較多,並且分佈隨機,幾乎沒有規律。但是繼續深挖,發現了這些事件當中,有相當一部分是內部開發者上報的
- Driven:因爲內部開發者處於調試原因,可能會進行本地 HOSTS 配置,或者開發代理進行訪問,數據不具備可代表性。所以,我們決定過濾內部賬號帶來的事件,並且增加 IDC 環境探針。
Round 2 - IDC 環境探針
所謂 IDC 環境探針,就是一個放到依賴所域名正式環境的 JS 腳本:
(function(global, name, domain){ global[name] = global[name] || {}; global[name][domain] = true; })(window, '__IDC_DOMAINS__', 'imgcache.qq.com');
探針工作原理很簡單:
- 正常訪問的用戶,可以加載到探針腳本,把腳本所指向域名寫入
__IDC_DOMAINS__
中 - 內部開發者,或者其它用戶,對域名進行了 HOSTS 指向,就會加載不到這個文件,在全局變量裏面自然就沒有
__IDC_DOMAINS__
信息
接下來就是第二輪的收斂。
- Capture:我們把採集到的 IDC 環境屬性,附加到事件對象上;
- Receive:根據此屬性,加白非 IDC 環境發生的事件;
- Analysis:分析數據發現,我們過濾了大量非 IDC 環境和內部賬號發生的資源加載失敗,但是依然沒有完全消滅這個問題。我們懷疑是用戶網絡問題導致資源加載失敗,但是缺少數據支撐;
- Driven:此時我們決定增加網絡探針,檢測用戶的網絡環境如何。
Round 3 - 網絡探針
繼續進行第三輪收斂。
- Capture:使用 XHR 請求 CDN 上的文件,上報延時、狀態碼、響應頭等數據;
- Receive:數據落地到 ES 之後;
- Analysis:我們發現,在用戶發生資源加載失敗的同時,探針的反饋的網絡結果卻是正常的;
- Driven:此時我們有充足的理由來懷疑,這些資源加載失敗,是由短時間的網絡抖動引起的,我們決定採取重試機制來解決這種隨機問題。
Round 4 - 採取重試機制
前三輪的收斂,我們都只是爲了定位問題而收集更加精確和詳細的數據,第四輪,我們對代碼進行了變更。
- Capture:在靜態資源加載失敗後,我們進行重試,並且採集下面兩種事件:
- 重試後成功的,上報重試成功事件;
- 重試失敗,用對應的靜態資源做一次網絡探測,上報探測結果;
- Receive:數據落地到 ES 之後;
- Analysis:我們觀察到兩個現象:
- 確實收集到了重試成功的情況,但是比例不大(不超過 20%);
- 加載失敗後重試又失敗的靜態文件,使用 XHR 探針加載的結果,竟然是成功的!
- Driven:我們沒有深究其中的原因,但是似乎給我們看到了一種可能的解決方案:如果重試失敗後,我使用 XHR 不是僅僅做探針,而是把加載到的內容直接拿來使用呢?
這裏重試的方式:對於 JavaScript 腳本,重新創建一個
script
標籤進行加載,而 CSS 樣式,就創建一個link
標籤進行加載。
Round 5 - XHR 重試
繼續上一輪的思路:
- Capture:在傳統重試失敗後,使用 XHR 進行重試,如 XHR 失敗,上報網絡信息;
- Receive:事件透傳到 ES 後;
- Analysis:隨着這個版本灰度的進行,重試的成功率不斷爬升,最終達到了讓人可喜的 80%;
- Driven:我們繼續對剩餘 20% 異常進行分析,循環收斂下去。
這一輪優化效果明顯,下面是灰度當天的效果趨勢:
而最近半個月,也始終保持着 80% ~ 90% 的成功率:
案例總結
在這無論下來,我們經理了下面的五個過程:
每個過程都按照 CRAD 模型進行迭代,最終得到一個可靠的效果。
從這個案例我們可以看到,CRAD 模型的優勢有:
- 每一步都在爲下一步做準備,目標清晰,環環相扣,不易丟失目標;
- 基於數據驅動,用數據說話,無論是做決策還是衡量效果都能讓人信服;
- 可以無限循環,爲了達到目標始終有手段,有工具,爲力爭完美的同學提供方法論支持;
0x04 乾貨
喝完雞湯,配點乾貨。
堆棧查看工具
前端的同學都知道,可以通過 window.onerror
事件來捕獲未處理的異常。假設捕獲上來的一個異常,並且上報的堆棧是這個:
TypeError: Cannot read property 'module' of undefined at Object.exec (https://img.qcloud.com/qcloud/iaas_web/build/cvm2.efe91e855d7432e402545e7d6c25d2d9.js:16:29828) at HTMLLIElement.<anonymous> (https://img.qcloud.com/qcloud/iaas_web/build/cvm2.efe91e855d7432e402545e7d6c25d2d9.js:25:6409) at HTMLDivElement.dispatch (https://img.qcloud.com/qcloud/app/qcconsole_web/dest/vendor.eb28ded1876760b8e90973c9f4813a2c.js?max_age=31536000&ver=20180329:1:248887) at HTMLDivElement.y.handle (https://img.qcloud.com/qcloud/app/qcconsole_web/dest/vendor.eb28ded1876760b8e90973c9f4813a2c.js?max_age=31536000&ver=20180329:1:245631)
這個堆棧,你看得出問題來嗎?
這個問題很常見,因爲我們發佈到 CDN 的文件,普遍是經過 UglifyJS 壓縮的,所以堆棧可讀性相當的差。開發看到這樣一個堆棧,多半不願意去看,而是選擇嘗試自己去復現,在復現不了的時候羣裏面扔一句 “我復現不了,能提供進一步的信息嗎?” 後不了了之了。
假如有下面的一個堆棧查看工具,又如何?
相信前端的同學,一眼就能找到問題。這裏的 p[e]
出現了可能爲 undefined
的情況。
平臺提供了這樣一個工具給業務方進行問題排查,大大提高了業務問題定位的效率。
好,這裏不賣瓜,我們來看下這當中的原理。
- 拿到原始堆棧字符串,使用 error-stack-parser 解析爲堆棧幀,每個堆棧幀包含三個最重要的字段:
url
- 源碼的 URL 地址line
- 堆棧位置行號col
- 堆棧位置列號
- 對於
url
,我們可以用於加載源碼內容,得到source
- source 使用 UglifyJs 反向美化成多行的代碼
prettysource
,並且同時生成sourcemap
- 堆棧幀中的
line
和col
通過sourcemap
反查,得到美化後對應的prettyline
和prettycol
- 將
prettysource
、prettyline
、prettycol
給到 Monaco Editor 渲染,就可以得到上述截圖的效果
說那麼多,不如貼代碼是吧:
var result = UglifyJS.minify(source, { output: { beautify: true }, sourceMap: { filename: 'pretty.js', url: 'pretty.js.map' } }); var code = result.code; var rawSourceMap = JSON.parse(result.map); var consumerPromise = new sourceMap.SourceMapConsumer(rawSourceMap); resolve( consumerPromise.then(function(consumer) { return { code: code, sourceMapConsumer: consumer } }) );
上面就是使用 UglifyJs 對壓縮代碼進行反向美化的核心代碼。下面給出 SourceMap 的使用源碼:
var code = result.code; var consumer = result.sourceMapConsumer; var position = consumer.generatedPositionFor({ source: '0', line: lineNumber, column: columnNumber }); parent.postMessage({ event: 'js-prettify-callback', payload: { hash: payload.hash, result: 'success', prettySource: code, prettyLineNumber: position.line, prettyColumnNumber: position.column + 1 } }, sourceOrigin);
完整源碼有興趣的讀者也可以下下來把玩把玩:
"Script Error" 的實驗
前端的同學如果用 window.onerror 事件做過監控,應該知道,跨域的腳本會給出 "Script Error." 提示,拿不到具體的錯誤信息和堆棧信息。
這裏讀者可以跟我一起做一個實驗,來深入瞭解這個事情。先做一下實驗準備:
app.js
const express = require('express'); const app = express(); app.use(express.static('./public')); app.listen(3000); app.listen(4000);
這裏創建一個 Node APP,只做靜態服務器,提供兩個端口用於做跨域實驗。
public/index.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <title>Script Error Test</title> <meta name="viewport" content="width=device-width, initial-scale=1"> </head> <body> <button id="btn-3000">3000</button> <button id="btn-4000">4000</button> <div> <pre id="info"></pre> </div> </body> <script> window.addEventListener('error', evt => { const info = evt.error ? evt.error.stack : evt.message; document.querySelector('#info').textContent = info; }); </script> <script src="http://127.0.0.1:3000/at3000.js"></script> <script src="http://127.0.0.1:4000/at4000.js"></script> </html>
創建一個靜態頁面,監聽 window.onerror
事件,並且輸出事件的堆棧。同時分別加載兩個域的 JS 文件。
public/at3000.js
(() => { const btn = document.querySelector('#btn-3000'); btn.addEventListener('click', () => { throw new Error('Fail 3000'); }); })();
public/at4000.js
(() => { const btn = document.querySelector('#btn-4000'); btn.addEventListener('click', () => { throw new Error('Fail 4000'); }); })();
這兩個 JS 文件都是監聽自己端口對應按鈕的點擊事件,然後直接扔出異常。
復現 Script Error
這個時候,我們啓動 Node APP:node app.js
,然後訪問 http://127.0.0.1:3000
。
分別點擊按鈕 3000 和 4000,我們發現,同域下面的 3000 按鈕點擊後,異常消息可以捕獲到。而跨域的 4000 按鈕,只有一個 Script Error。
我們復現了 "Script Error."!
有同學舉手,我知道,只要加一個跨域頭就可以了!
Access-Control-Allow-Origin
沒錯,我們可以給靜態文件服務器加上跨域協議頭:
app.use(express.static('./public', { setHeaders(res) { res.set('access-control-allow-origin', res.req.get('origin')); res.set('access-control-allow-credentials', 'true'); } }));
同時,加載 JS 的時候,加上跨域聲明:
<script src="http://127.0.0.1:4000/at4000.js" crossorigin="anonymous"></script>
這樣,無論 3000 還是 4000 按鈕,我們點擊都能獲得異常信息。
但是,這個方案有兩個致命的弱點:
- 如果 JS 聲明瞭
crossorigin="anonymous"
但是響應頭沒有正確,JS 會直接無法執行 - 我們並不總是有靜態服務器的配置權限,跨域頭不是相加就能加
奇淫技巧
我們可以不加跨域頭,而只是在 JS 文件加載之前再加載一個「特別的」JS:
<script src="http://127.0.0.1:3000/inject-event-target.js"></script> <script src="http://127.0.0.1:3000/at3000.js"></script> <script src="http://127.0.0.1:4000/at4000.js"></script>
這個神奇的 inject-event-target.js
可以讓我們在沒有跨域頭的情況下,拿到 4000 按鈕事件處理器的執行異常信息。
如果你覺得申請,請繼續往下閱讀。這個魔法 JS,其實也很簡單:
(() => { const originAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function (type, listener, options) { const wrappedListener = function (...args) { try { return listener.apply(this, args); } catch (err) { throw err; } } return originAddEventListener.call(this, type, wrappedListener, options); } })();
這個原理也非筆者原創,而是從這篇文章學習而來。
簡單解釋一下:
- 改寫了 EventTarget 的 addEventListener 方法;
- 對傳入的 listener 進行包裝,返回包裝過的 listener,對其執行進行 try-catch;
- 瀏覽器不會對 try-catch 起來的異常進行跨域攔截,所以 catch 到的時候,是有堆棧信息的;
- 重新 throw 出來異常的時候,執行的是同域代碼,所以 window.onerror 捕獲的時候不會丟失堆棧信息;
實際上,利用包裝 addEventListener,我們還可以達到「擴展堆棧」的效果:
我們不僅知道異常堆棧,而且還知道導致該異常的事件處理器,是在何處添加進去的。實現這個效果,也很簡單:
(() => { const originAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function (type, listener, options) { + // 捕獲添加事件時的堆棧 + const addStack = new Error(`Event (${type})`).stack; const wrappedListener = function (...args) { try { return listener.apply(this, args); } catch (err) { + // 異常發生時,擴展堆棧 + err.stack += '\n' + addStack; throw err; } } return originAddEventListener.call(this, type, wrappedListener, options); } })();
同樣的道理,我們也可以對 setTimeout、setInterval、requestAnimationFrame 甚至 XMLHttpRequest 做這樣的攔截,得到一些我們本來得不到的信息。
實驗到此結束,完整源碼也可以在這裏下載到:
最後
喜歡本文的,請不要吝嗇點贊轉發,特別喜歡的,歡迎給我打賞😍。
同時歡迎在評論區和我討論。
訂閱我們的專欄「前端之心」,每週都會有乾貨。