洞悉業務 - 騰訊雲控制檯業務監控總結

今年,騰訊雲控制檯的事故數量隨着用戶量、業務規模和業務複雜度的增長而增長。盤點這些事故之後,我們發現當中有不少問題是用戶“幫”我們發現的。

主動發現並收斂問題,是團隊的主要目標。在這個大前提下,團隊利用 ELK 對 Node 層日誌系統進行升級改造,並搭建了前端監控系統。兩個方案讓我們撥開迷霧,對自己的業務狀態有了一個清晰的瞭解。

Node 層接入 ELK 框架,團隊之前有過分享,在此不再贅述。本文主要分享一下利用 ELK 搭建前端監控系統的一些經驗。

0x00 背景

騰訊雲的控制檯並不是由單個業務團隊完成的,而是採取平臺 + 業務接入的方式。

  • 平臺方負責控制檯框架的維護,以及支持各個業務團隊的業務接入
  • 業務團隊進行控制檯前端頁面的開發

之前,平臺在監控投入上疲軟,導致兩個尷尬:

  • 測試沒有覆蓋的問題,無法先於用戶發現,非常被動
  • 業務出現問題反饋到平臺方定位時,平臺缺少足夠的數據和工具來快速排查

部分業務團隊也自己採取了一些監控手段。然而,不是所有業務團隊都有這個時間和精力在監控上進行投入。平臺進行統一監控能力的建設,顯得刻不容緩。

0x01 目標

建設前端監控系統,我們希望:

  • 業務方無感知接入,平臺上報前端的異常事件
  • 提供工具給到業務方和一線進行問題定位
  • 基於數據建設精確化告警,異常觸達平臺和業務方
  • 能夠基於數據衡量問題的收斂效果,並評估業務健康度

0x02 模型

平臺方採取 CRAD 模型來建設監控體系,並且在實踐上取得了一定的成功經驗。

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
  • 堆棧幀中的 linecol 通過 sourcemap 反查,得到美化後對應的 prettylineprettycol
  • prettysourceprettylineprettycol 給到 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。

點擊 3000 按鈕
點擊 4000 按鈕

我們復現了 "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 會直接無法執行
  • 我們並不總是有靜態服務器的配置權限,跨域頭不是相加就能加
聲明瞭 crossorigin 但是沒有響應跨域頭的 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 按鈕事件處理器的執行異常信息。

點擊 3000
點擊 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 做這樣的攔截,得到一些我們本來得不到的信息。

實驗到此結束,完整源碼也可以在這裏下載到:

最後

喜歡本文的,請不要吝嗇點贊轉發,特別喜歡的,歡迎給我打賞😍。

同時歡迎在評論區和我討論。

訂閱我們的專欄「前端之心」,每週都會有乾貨。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章