前端異常監控

binbinsilk發表於2018-07-21

本文大致圍繞下面幾點展開討論:

  1. JS 處理異常的方式
  2. 上報方式
  3. 異常監控上報常見問題

JS 異常處理

對於 Javascript 而言,我們面對的僅僅只是異常,異常的出現不會直接導致 JS 引擎崩潰,最多隻會使當前執行的任務終止。

  1. 當前程式碼塊將作為一個任務壓入任務佇列中,JS 執行緒會不斷地從任務佇列中提取任務執行。
  2. 當任務執行過程中出現異常,且異常沒有捕獲處理,則會一直沿著呼叫棧一層層向外丟擲,最終終止當前任務的執行。
  3. JS 執行緒會繼續從任務佇列中提取下一個任務繼續執行。
<script>
  error
  console.log('永遠不會執行');
</script>
<script>
  console.log('我繼續執行')
</script>
複製程式碼

在對指令碼錯誤進行上報之前,我們需要對異常進行處理,程式需要先感知到指令碼錯誤的發生,然後再談異常上報。

指令碼錯誤一般分為兩種:語法錯誤,執行時錯誤。

下面就談談幾種異常監控的處理方式:

try-catch 異常處理

try-catch 在我們的程式碼中經常見到,通過給程式碼塊進行 try-catch 進行包裝後,當程式碼塊發生出錯時 catch 將能捕捉到錯誤的資訊,頁面也將可以繼續執行。

但是 try-catch 處理異常的能力有限,只能捕獲捉到執行時非非同步錯誤,對於語法錯誤和非同步錯誤就顯得無能為力,捕捉不到。

示例:執行時錯誤

try {
  error    // 未定義變數 
} catch(e) {
  console.log('我知道錯誤了');
  console.log(e);
}
複製程式碼

然而對於語法錯誤和非同步錯誤就捕捉不到了。

示例:語法錯誤

try {
  var error = 'error';   // 大寫分號
} catch(e) {
  console.log('我感知不到錯誤');
  console.log(e);
}
複製程式碼

一般語法錯誤在編輯器就會體現出來,常表現的錯誤資訊為:

Uncaught SyntaxError: Invalid or unexpected token xxx

這樣。但是這種錯誤會直接丟擲異常,常使程式崩潰,一般在編碼時候容易觀察得到。

示例:非同步錯誤

try {
  setTimeout(() => {
    error        // 非同步錯誤
  })
} catch(e) {
  console.log('我感知不到錯誤');
  console.log(e);
}
複製程式碼

除非你在 setTimeout 函式中再套上一層 try-catch,否則就無法感知到其錯誤,但這樣程式碼寫起來比較囉嗦。

window.onerror 異常處理

window.onerror 捕獲異常能力比 try-catch 稍微強點,無論是非同步還是非非同步錯誤,onerror 都能捕獲到執行時錯誤。

示例:執行時同步錯誤

/**
 * @param {String}  msg    錯誤資訊
 * @param {String}  url    出錯檔案
 * @param {Number}  row    行號
 * @param {Number}  col    列號
 * @param {Object}  error  錯誤詳細資訊
 */
 window.onerror = function (msg, url, row, col, error) {
  console.log('我知道錯誤了');
  console.log({
    msg,  url,  row, col, error
  })
  return true;
};
error;
複製程式碼

示例:非同步錯誤

window.onerror = function (msg, url, row, col, error) {
  console.log('我知道非同步錯誤了');
  console.log({
    msg,  url,  row, col, error
  })
  return true;
};
setTimeout(() => {
  error;
});
複製程式碼

然而 window.onerror 對於語法錯誤還是無能為力,所以我們在寫程式碼的時候要儘可能避免語法錯誤的,不過一般這樣的錯誤會使得整個頁面崩潰,還是比較容易能夠察覺到的。

在實際的使用過程中,onerror 主要是來捕獲預料之外的錯誤,而 try-catch 則是用來在可預見情況下監控特定的錯誤,兩者結合使用更加高效。

需要注意的是,window.onerror 函式只有在返回 true 的時候,異常才不會向上丟擲,否則即使是知道異常的發生控制檯還是會顯示

Uncaught Error: xxxxx

關於 window.onerror 還有兩點需要值得注意

  1. 對於 onerror 這種全域性捕獲,最好寫在所有 JS 指令碼的前面,因為你無法保證你寫的程式碼是否出錯,如果寫在後面,一旦發生錯誤的話是不會被 onerror 捕獲到的。
  2. 另外 onerror 是無法捕獲到網路異常的錯誤。

當我們遇到

<img src="./404.png">

報 404 網路請求異常的時候,onerror 是無法幫助我們捕獲到異常的。

<script>
  window.onerror = function (msg, url, row, col, error) {
    console.log('我知道非同步錯誤了');
    console.log({
      msg,  url,  row, col, error
    })
    return true;
  };
</script>
<img src="./404.png">
複製程式碼

由於網路請求異常不會事件冒泡,因此必須在捕獲階段將其捕捉到才行,但是這種方式雖然可以捕捉到網路請求的異常,但是無法判斷 HTTP 的狀態是 404 還是其他比如 500 等等,所以還需要配合服務端日誌才進行排查分析才可以。

<script>
window.addEventListener('error', (msg, url, row, col, error) => {
  console.log('我知道 404 錯誤了');
  console.log(
    msg, url, row, col, error
  );
  return true;
}, true);
</script>
<img src="./404.png" alt="">
複製程式碼

這點知識還是需要知道,要不然使用者訪問網站,圖片 CDN 無法服務,圖片載入不出來而開發人員沒有察覺就尷尬了。

Promise 錯誤

通過 Promise 可以幫助我們解決非同步回撥地獄的問題,但是一旦 Promise 例項丟擲異常而你沒有用 catch 去捕獲的話,onerror 或 try-catch 也無能為力,無法捕捉到錯誤。

window.addEventListener('error', (msg, url, row, col, error) => {
  console.log('我感知不到 promise 錯誤');
  console.log(
    msg, url, row, col, error
  );
}, true);
Promise.reject('promise error');
new Promise((resolve, reject) => {
  reject('promise error');
});
new Promise((resolve) => {
  resolve();
}).then(() => {
  throw 'promise error'
});
複製程式碼

雖然在寫 Promise 例項的時候養成最後寫上 catch 函式是個好習慣,但是程式碼寫多了就容易糊塗,忘記寫 catch。

所以如果你的應用用到很多的 Promise 例項的話,特別是你在一些基於 promise 的非同步庫比如 axios 等一定要小心,因為你不知道什麼時候這些非同步請求會丟擲異常而你並沒有處理它,所以你最好新增一個 Promise 全域性異常捕獲事件 unhandledrejection。

window.addEventListener("unhandledrejection", function(e){
  e.preventDefault()
  console.log('我知道 promise 的錯誤了');
  console.log(e.reason);
  return true;
});
Promise.reject('promise error');
new Promise((resolve, reject) => {
  reject('promise error');
});
new Promise((resolve) => {
  resolve();
}).then(() => {
  throw 'promise error'
});
複製程式碼

當然,如果你的應用沒有做 Promise 全域性異常處理的話,那很可能就像某乎首頁這樣:

異常上報方式

監控拿到報錯資訊之後,接下來就需要將捕捉到的錯誤資訊傳送到資訊收集平臺上,常用的傳送形式主要有兩種:

  1. 通過 Ajax 傳送資料
  2. 動態建立 img 標籤的形式

例項 - 動態建立 img 標籤進行上報

function report(error) {
  var reportUrl = 'http://xxxx/report';
  new Image().src = reportUrl + 'error=' + error;
}
複製程式碼

監控上報常見問題

Script error 指令碼錯誤是什麼

因為我們線上上的版本,經常做靜態資源 CDN 化,這就會導致我們常訪問的頁面跟指令碼檔案來自不同的域名,這時候如果沒有進行額外的配置,就會容易產生 Script error。

可通過

npm run nocors

檢視效果。

Script error 是瀏覽器在同源策略限制下產生的,瀏覽器處於對安全性上的考慮,當頁面引用非同域名外部指令碼檔案時中丟擲異常的話,此時本頁面是沒有權利知道這個報錯資訊的,取而代之的是輸出 Script error 這樣的資訊。

這樣做的目的是避免資料洩露到不安全的域中,舉個簡單的例子,

<script src="xxxx.com/login.html"></script>
複製程式碼

上面我們並沒有引入一個 js 檔案,而是一個 html,這個 html 是銀行的登入頁面,如果你已經登入了,那 login 頁面就會自動跳轉到

Welcome xxx…

,如果未登入則跳轉到

Please Login…

,那麼報錯也會是

Welcome xxx… is not defined,Please Login… is not defined

,通過這些資訊可以判斷一個使用者是否登入他的帳號,給入侵者提供了十分便利的判斷渠道,這是相當不安全的。

介紹完背景後,那麼我們應該去解決這個問題?

首先可以想到的方案肯定是同源化策略,將 JS 檔案內聯到 html 或者放到同域下,雖然能簡單有效地解決 script error 問題,但是這樣無法利用好檔案快取和 CDN 的優勢,不推薦使用。正確的方法應該是從根本上解決 script error 的錯誤。

跨源資源共享機制( CORS )

首先為頁面上的 script 標籤新增 crossOrigin 屬性

// http://localhost:8080/index.html
<script>
  window.onerror = function (msg, url, row, col, error) {
    console.log('我知道錯誤了,也知道錯誤資訊');
    console.log({
      msg,  url,  row, col, error
    })
    return true;
  };
</script>
<script src="http://localhost:8081/test.js" crossorigin></script>

// http://localhost:8081/test.js
setTimeout(() => {
  console.log(error);
});
複製程式碼

當你修改完前端程式碼後,你還需要額外給後端在響應頭裡加上

Access-Control-Allow-Origin: localhost:8080

,這裡我以 Koa 為例。

const Koa = require('koa');
const path = require('path');
const cors = require('koa-cors');
const app = new Koa();

app.use(cors());
app.use(require('koa-static')(path.resolve(__dirname, './public')));

app.listen(8081, () => {
  console.log('koa app listening at 8081')
});
複製程式碼

讀者可通過

npm run cors

詳細的跨域知識我就不展開了,有興趣可以看看我之前寫的文章:跨域,你需要知道的全在這裡

你以為這樣就完了嗎?並沒有,下面就說一些 Script error 你不常遇見的點:

我們都知道 JSONP 是用來跨域獲取資料的,並且相容性良好,在一些應用中仍然會使用到,所以你的專案中可能會用這樣的程式碼:

// http://localhost:8080/index.html
window.onerror = function (msg, url, row, col, error) {
  console.log('我知道錯誤了,但不知道錯誤資訊');
  console.log({
    msg,  url,  row, col, error
  })
  return true;
};
function jsonpCallback(data) {
  console.log(data);
}
const url = 'http://localhost:8081/data?callback=jsonpCallback';
const script = document.createElement('script');
script.src = url;
document.body.appendChild(script);
複製程式碼

因為返回的資訊會當做指令碼檔案來執行,一旦返回的指令碼內容出錯了,也是無法捕捉到錯誤的資訊。

解決辦法也不難,跟之前一樣,在新增動態新增指令碼的時候加上 crossOrigin,並且在後端配上相應的 CORS 欄位即可.

const script = document.createElement('script');
script.crossOrigin = 'anonymous';
script.src = url;
document.body.appendChild(script);
複製程式碼

讀者可以通過

npm run jsonp

檢視效果

知道原理之後你可能會覺得沒什麼,不就是給每個動態生成的指令碼新增 crossOrigin 欄位嘛,但是在實際工程中,你可能是面向很多庫來程式設計,比如使用 jQuery,Seajs 或者 webpack 來非同步載入指令碼,許多庫封裝了非同步載入指令碼的能力,以 jQeury 為例你可能是這樣來觸發非同步指令碼。

$.ajax({
  url: 'http://localhost:8081/data',
  dataType: 'jsonp',
  success: (data) => {
    console.log(data);
  }
})
複製程式碼

假如這些庫中沒有提供 crossOrigin 的能力的話(jQuery jsonp 可能有,假裝你不知道),那你只能去修改人家寫的原始碼了,所以我這裡提供一個思路,就是去劫持 document.createElement,從根源上去為每個動態生成的指令碼新增 crossOrigin 欄位。

document.createElement = (function() {
  const fn = document.createElement.bind(document);
  return function(type) {
    const result = fn(type);
    if(type === 'script') {
      result.crossOrigin = 'anonymous';
    }
    return result;
  }
})();
window.onerror = function (msg, url, row, col, error) {
  console.log('我知道錯誤了,也知道錯誤資訊');
  console.log({
    msg,  url,  row, col, error
  })
  return true;
};
$.ajax({
  url: 'http://localhost:8081/data',
  dataType: 'jsonp',
  success: (data) => {
    console.log(data);
  }
})
複製程式碼

效果也是一樣的,讀者可以通過

npm run jsonpjq

來檢視效果:

這樣重寫 createElement 理論上沒什麼問題,但是入侵了原本的程式碼,不保證一定不會出錯,在工程上還是需要多嘗試下看看再使用,可能存在相容性上問題,如果你覺得會出現什麼問題的話也歡迎留言討論下。

關於 Script error 的問題就寫到這裡,如果你理解了上面的內容,基本上絕大部分的 Script error 都能迎刃而解。

window.onerror 能否捕獲 iframe 的錯誤

當你的頁面有使用 iframe 的時候,你需要對你引入的 iframe 做異常監控的處理,否則一旦你引入的 iframe 頁面出現了問題,你的主站顯示不出來,而你卻渾然不知。

首先需要強調,父視窗直接使用 window.onerror 是無法直接捕獲,如果你想要捕獲 iframe 的異常的話,有分好幾種情況。

如果你的 iframe 頁面和你的主站是同域名的話,直接給 iframe 新增 onerror 事件即可。

<iframe src="./iframe.html" frameborder="0"></iframe>
<script>
  window.frames[0].onerror = function (msg, url, row, col, error) {
    console.log('我知道 iframe 的錯誤了,也知道錯誤資訊');
    console.log({
      msg,  url,  row, col, error
    })
    return true;
  };
</script>
複製程式碼

讀者可以通過

npm run iframe

檢視效果:

如果你嵌入的 iframe 頁面和你的主站不是同個域名的,但是 iframe 內容不屬於第三方,是你可以控制的,那麼可以通過與 iframe 通訊的方式將異常資訊拋給主站接收。與 iframe 通訊的方式有很多,常用的如:postMessage,hash 或者 name 欄位跨域等等,這裡就不展開了,感興趣的話可以看:跨域,你需要知道的全在這裡

如果是非同域且網站不受自己控制的話,除了通過控制檯看到詳細的錯誤資訊外,沒辦法捕獲,這是出於安全性的考慮,你引入了一個百度首頁,人家頁面報出的錯誤憑啥讓你去監控呢,這會引出很多安全性的問題。

壓縮程式碼如何定位到指令碼異常位置

線上的程式碼幾乎都經過了壓縮處理,幾十個檔案打包成了一個並醜化程式碼,當我們收到

a is not defined

的時候,我們根本不知道這個變數 a 究竟是什麼含義,此時報錯的錯誤日誌顯然是無效的。

第一想到的辦法是利用 sourcemap 定位到錯誤程式碼的具體位置,詳細內容可以參考:Sourcemap 定位指令碼錯誤

另外也可以通過在打包的時候,在每個合併的檔案之間新增幾行空格,並相應加上一些註釋,這樣在定位問題的時候很容易可以知道是哪個檔案報的錯誤,然後再通過一些關鍵詞的搜尋,可以快速地定位到問題的所在位置。

收集異常資訊量太多,怎麼辦

如果你的網站訪問量很大,假如網頁的 PV 有 1kw,那麼一個必然的錯誤傳送的資訊就有 1kw 條,我們可以給網站設定一個採集率:

Reporter.send = function(data) {
  // 只採集 30%
  if(Math.random() < 0.3) {
    send(data)      // 上報錯誤資訊
  }
}
複製程式碼

這個採集率可以通過具體實際的情況來設定,方法多樣化,可以使用一個隨機數,也可以具體根據使用者的某些特徵來進行判定。

上面差不多是我對前端程式碼監控的一些理解,說起來容易,但是一旦在工程化運用,難免需要考慮到相容性等種種問題,讀者可以通過自己的具體情況進行調整,前端程式碼異常監控對於我們的網站的穩定性起著至關重要的作用。如若文中所有不對的地方,還望指正。


相關文章