讓前端監控資料採集更高效

馬蜂窩技術發表於2019-04-19

隨著業務的快速發展,我們對生產環境下的問題感知能力越來越關注。作為距離使用者最近的一層,前端的表現是否可靠、穩定、好用,很大程度上決定著使用者對整個產品的體驗和感受。因此,對於前端的監控不容忽視。

搭建一套前端監控平臺需要考慮的方面很多,比如資料採集、埋點模式、資料處理和分析、報警以及監控平臺在具體業務中的應用等等。在這所有環節中,準確、完整、全面的資料採集是一切的前提,也為後續的使用者精細化運營提供基礎。

前端技術的日新月異給資料採集也帶來了變化和挑戰,傳統的手工打點模式已經不能滿足需求。如何在新的技術背景下讓前端資料採集工作更加完善、高效,是本文討論的重點。

前端監控資料採集

在採集資料之前,首先要考慮採集什麼樣的資料。我們重點關注兩類資料,一類是與使用者體驗相關的,如首屏時間、檔案載入時間、頁面效能等;另外是幫助我們及時感知產品上線後是否出現異常的,比如資源錯誤、API 響應時間等。具體來說,我們對前端的資料採集具體主要分為:

  • 路由切換 (href、hashchange、pushState)

  • JsError

  • 效能 (performance)

  • 資源錯誤

  • API

  • 日誌上報

路由切換

Vue、React、Angular 等前端技術的快速發展使單頁面應用盛行。我們都知道,傳統的頁面應用是用一些超連結來實現頁面切換和跳轉的,而單頁面應用是使用各自的路由系統來管理前端的每一個頁面切換,例如 vue-router、react-router 等,跳轉時僅重新整理區域性資源 ,js、css 等公共資源只需要載入一次,這就使傳統網頁進入離開的方式只有第一次開啟能被記錄。單頁應用後續所有路由切換的方式有兩種,一種是 Hash,一種是 HTML5 推出的 History API。

1. href

href 為頁面初始化的第一次進入,這裡只需要單純上報「進入頁面」事件即可。

2. hashchange

Hash 路由一個明顯的標誌是帶有「 # 」。Hash 的優勢是相容性更好,但問題在於 URL 中一直存在「 # 」並不美觀。我們主要通過監聽 URL 中的 hashchange 來捕獲具體的 hash 值進行檢測。

window.addEventListener('hashchange'function() {
    // 上報【進入頁面】事件
}, true)

複製程式碼

需要注意的是,在新版 vue-router 中如果瀏覽器支援 history,即使 mode 選擇 hash 也會優先選擇 history 模式,雖然表現形式暫時還是 # 號,但實際上是模擬的,所以千萬不要認為自己在 mode 選擇了hash 就一定會是 hash。

3. History API

History 利用了 HTML5 History Interface 中新增的 pushState() 和 replaceState() 方法進行路由切換,是目前主流的無重新整理切換路由方式。與 hashchange 只能改變 # 後面的程式碼片段相比,History API (pushState、replaceState) 給了前端完全的自由。

PopState 是瀏覽器返回事件的回撥,但是更新路由的 pushState、replaceState 並沒有回撥事件,因此,還需要分別在 history.pushState() 和 history.replaceState() 方法裡處理 URL 的變化。在這裡,我們運用到了一種類似 Java 的 AOP 程式設計思想,對 pushState 和 replaceState 進行改造。

AOP (Aspect-oriented programming)即面向切面程式設計,提倡針對同一類問題進行統一處理。AOP 的核心思想是讓某個模組能夠重用,它採用橫向抽取機制,將功能程式碼從業務邏輯程式碼中分離出來,擴充套件功能而不修改原始碼,相比封裝來說隔離得更加徹底。

下面介紹我們的具體改造方式:

// 第一階段:我們對原生方法進行包裝,呼叫前執行 dispatchEvent 了一個同樣的事件
function aop (type) {
    var source = window.history[type];
    return function () {
        var event = new Event(type);
        event.arguments = arguments;
        window.dispatchEvent(event);
        var rewrite = source.apply(this, arguments);
        return rewrite;
    };
}

// 第二階段:將 pushState 和 replaceState 進行基於 AOP 思想的程式碼注入
window.history.pushState = aop('pushState');
window.history.replaceState = aop('replaceState'); // 更改路由,不會留下歷史記錄

// 第三階段:捕獲pushState 和 replaceState
window.addEventListener('pushState'function() {
    // 上報【進入頁面】事件
}, true)
window.addEventListener('replaceState'function() {
    // 上報【進入頁面】事件
}, true)

複製程式碼

window.history.pushState 實際呼叫關係如圖:

讓前端監控資料採集更高效

至此,我們對 pushState、replaceState 改造完畢,實現了有效地捕獲路由切換。可以看到,我們在不侵入業務程式碼的情況下,對 window.history.pushState 進行了擴充套件,在呼叫的同時會主動 dispatchEvent 一個 pushState。

但在這裡我們也能看到一個弊端,就是如果 AOP 代理函式發生 JS 錯誤,將會阻斷後續的呼叫關係,使實際的 window.history.pushState 無法被呼叫。所以在使用此方式的時候,要對 AOP 代理函式的內容做好完善的 try catch,來防止業務上出現異常。

_*_Tips:想自動捕獲頁面停留時間只需要在下一個進入頁面事件觸發時,通過上一個頁面的打點時間和當前時間做差值即可,這時候可以上報一個【離開頁面】事件。

JsError

前端專案中,由於 JavaScript 本身是一個弱型別語言,加上瀏覽器環境的複雜性、網路問題等,很容易發生錯誤。因此做好網頁錯誤監控,不斷優化程式碼,提高程式碼健壯性是一項很重要的工作。

JsError 的捕獲可以幫助我們分析和監控線上問題,它與我們在 Chrome 瀏覽器的除錯工具 Console 中看到的內容一致。

1. window.onerror

我們使用 window.onerror 捕獲一般情況下 JS 錯誤的異常資訊。捕獲 JS 錯誤的方式有兩種,window.onerror 和 window.addEventListener(‘error’)。一般情況下,捕獲 JS 異常不推薦使用 addEventListener(‘error’),主要是因為它沒有堆疊資訊,而且還需要對捕獲到的資訊做區分,因為它會將所有異常資訊捕獲到,包括資源載入錯誤等。

window.onerror = function (msg, url, lineno, colno, stack) {
    // 上報 【js錯誤】事件
}

複製程式碼

2. Uncaught (in promise)

當 Promise 內發生 JS 錯誤或者 reject 資訊未被業務處理的情況時,會丟擲一個 unhandledrejection,並且這個錯誤不會被 window.onerror 以及 window.addEventListener('error')  捕獲,這裡需要用專門的 window.addEventListener('unhandledrejection')  進行捕獲處理:

window.addEventListener('unhandledrejection'function (e) {
    var reg_url = /\(([^)]*)\)/;
    var fileMsg = e.reason.stack.split('\n')[1].match(reg_url)[1];
    var fileArr = fileMsg.split(':');
    var lineno = fileArr[fileArr.length - 2];
    var colno = fileArr[fileArr.length - 1];
    var url = fileMsg.slice(0, -lno.length - cno.length - 2);}, true);
    var msg = e.reason.message;
    // 上報 【js錯誤】事件
}

複製程式碼

我們注意到 unhandledrejection 因為繼承自 PromiseRejectionEvent,PromiseRejectionEvent 又繼承自 Event,所以 msg、url、lineno、colno、stack 以字串形式放到了 e.reason.stack 中,我們需要解析出來上述引數來和 onerror 引數對齊,為後續監控平臺的指標統一化打下基礎。

3. 常見問題

  • "Script error."

如果出現捕獲的 msg 全部為 "Script error." ,問題在於你的 JS 地址和當前網頁不在同一個域下。因為我們要經常線上上的版本做靜態資源 CDN 化,會導致常訪問的頁面跟指令碼檔案來自不同的域名。這時如果沒有進行額外的配置,瀏覽器出於安全方面的設計就容易出現 "Script error."。我們可以利用目前流行的 Webpack 打包工具來處理此類問題。

// webpack config 配置
// 處理 html 注入 js 新增跨域標識
plugins: [
    new HtmlWebpackPlugin({
      filename: 'html/index.html',
      template: HTML_PATH,
      attributes: {
        crossorigin: 'anonymous'
      }
    }),
    new HtmlWebpackPluginCrossorigin({
      inject: true
    })
]

// 處理按需載入的 js 新增跨域標識
output: {
    crossOriginLoading: true
}

複製程式碼
  • SourceMap

大部分場景下,生產環境中的程式碼都是經過壓縮合並的,這使得我們捕獲到的錯誤很難對映到具體的原始碼,為我們解決問題帶來很大困擾,這裡簡要提出 2 個解決方案的思路。

生產環境我們需要新增 sourceMap 配置,這會導致安全隱患,因為這樣外網就可以通過 sourceMap 進行原始碼對映。為了降低風險,我們可以通過如下方式:

  1. 將 sourceMap 生成的 .map 檔案設定公司內網訪問,降低原始碼安全風險

  2. 在釋出程式碼到 CDN 的時候,將 .map 檔案儲存到公司內網下

這時我們已經擁有了 .map 檔案,後續要做的就是通過捕獲到的 lineno、colno、url 呼叫 mozilla/source-map 庫進行原始碼對映,即可拿到真實的原始碼錯誤資訊。

效能

效能指標的獲取相對比較簡單,在 onload 之後讀取 window.performance 即可,裡面包含了效能、記憶體等資訊。這部分內容在很多現有的文章中都有介紹,因篇幅所限不在本文做過多展開,之後在相關主題文章中我們會有相關探討,感興趣的朋友可以新增「馬蜂窩技術」公眾號持續關注。

資源錯誤

首先我們要明確下資源錯誤捕獲的使用場景,更多的是感知 DNS 劫持 及 CDN 節點異常等,具體方式如下:

window.addEventListener('error'function (e) {
    var target = e.target || e.srcElement;
    if (target instanceof HTMLScriptElement) {
        // 上報 【資源錯誤】事件
    }
}, true)

複製程式碼

這裡只做基本演示,實際環境中我們會關心更多的 Element 錯誤,如 css、img、woff 等,大家可以根據不同的場景自行新增。

_*資源錯誤的使用場景更多依賴其他幾個維度,如:_地域、運營商等,後續的篇幅中我們會具體講解。

API

市面上主流的框架(如 Axios、jQuery.ajax 等)中,基本上所有的 API 請求都是基於xmlHttpRequest 或者 fetch,所以捕獲全域性介面錯誤的方式就是封裝 xmlHttpRequest 或者 fetch。這裡,我們的 SDK 仍然使用到上文提及的 AOP 思想,對 API 進行攔截。

1. XmlHttpRequest

var xhr = window.XMLHttpRequest;
var _open = xhr.prototype.open;
var _send = xhr.prototype.send;
var attr = {};
var openReplacement = function (method, url) {
    // 可以儲存method、url、時間打點等資訊
    attr.duration = new Date().getTime();
    _open.apply(this, arguments);
}
var sendReplacement = function () {
    methods.addEvent(this, 'readystatechange'function (attr) {
        // 可以儲存response的status、計算客戶端實際響應時間
        attr.status = this.status;
        attr.duration = new Date().getTime() - attr.duration;
        // 上報【API】事件
    }.bind(this, , JSON.parse(JSON.stringify(attr))));
    _send.apply(this, arguments);
}
xmlhttp.prototype.open = openReplacement;
xmlhttp.prototype.send = sendReplacement;

複製程式碼

2. Fetch

需要注意的是,API 攔截一定要對 SDK 自己上報的 API 設定好忽略,否則將會導致迴圈上報問題。

var _fetch = window.fetch;
window.fetch = function () {
    var attr = {
        method: arguments[1].method,
        url: arguments[0],
        duration: new Date().getTime()
    };

    return _fetch.apply(this, arguments).then(res => {
        attr.status = res.status;
        attr.duration = new Date().getTime() - attr.duration;
        // 上報【API】事件
        return res;
    });
}

複製程式碼

日誌上報

為了監控前端應用是否正常執行,通常會在前端收集錯誤與效能等資料,最終將這些資料上報到服務端。因為日誌上報並不是應用的主要功能邏輯,優先順序比較低,所以我們在確保日誌資料上報更高效的同時,還應該考慮如何儘可能地減少與其他關鍵操作的資源爭搶。

1. sendBeacon

navigator.sendBeacon() 方法主要用於滿足統計和診斷程式碼的需要。這些程式碼通常會在解除安裝文件之前,嘗試通過 HTTP 將少量資料非同步傳輸到 Web 伺服器。它解決了日誌上報在 unload 時成功率很低的問題。我們在埋點時有很多對離開頁面時上報的需求,因為 SendBeacon 是非同步的,不會影響當前頁到下一個頁面的跳轉速度,可以更可靠地保障事件上報成功率,並且不影響路由切換。

window.navigator.sendBeacon('上報事件的api''資料引數')

複製程式碼

2. img.src

當瀏覽器不支援 navigator.sendBeacon 時,我們可以採用模擬圖片載入的方式傳送日誌上報事件,且不會存在跨域問題。

var img = new Image();
img.src = API + '?' + '資料引數'

複製程式碼

3. 關於 XmlHttpRequest

這裡不推薦用 XmlHttpRequest。XHR 雖然支援非同步請求,直接傳送資料到後端,但是會受到跨域和同源的限制。而通過日誌上報 API 跟業務是不在一個域下的,如果採用這種模式需要設定 Access-Control-Allow-Origin:* 跨域,非常不方便,並且在 unload 情況下上報發生的丟包率最高。

總結來看,日誌上報推薦採用 sendBeacon -> img.src。在不影響使用者路由切換和阻塞使用者的情況下丟包率可以控制在 10%-30%,具體要看使用者群體對應的環境。

小結

高效的前端資料採集對於搭建前端監控平臺來說非常關鍵。本文我們分享了馬蜂窩在保證資料採集及時、準確、全面等方面的一些思路和實踐。需要提示大家注意的是,文中涉及到的演示只做了核心程式碼的關鍵描述,不具備生產使用,我們在實際使用中需要做好相容及容錯。

本文也將作為馬蜂窩前端監控平臺系列文章的開篇,今後還將陸續推出埋點模式、資料處理和分析、報警以及監控平臺在具體業務中的應用等內容,歡迎大家持續關注。

本文作者:王崢,馬蜂窩大資料平臺前端技術專家。

(馬蜂窩技術原創內容,轉載務必註明出處儲存文末二維碼圖片,謝謝配合。)

關注馬蜂窩技術,找到更多你想要的內容

讓前端監控資料採集更高效

相關文章