別再讓你的Web頁面在用戶瀏覽器端裸奔
- 頁面在用戶那里運行,如果10%的用戶頁面出現問題而自己本地沒有辦法重現?
- 如何先一步了解到前端出現的問題,而不是等用戶反饋?
- 能不能像查看服務端日志一樣來定位前端頁面運行的問題?
前端在業(yè)務復雜度越來越高的情況下,本地即使做了充分的測試,依照caniuse做了很多兼容,依然無法讓人放心頁面能否正常運行或者運行得怎么樣。
當一個前端頁面發(fā)布出去了之后,頁面所運行的設備、瀏覽器、網絡環(huán)境、用戶操作習慣等等因素都可能是造成頁面不正常的原因。
所以對前端頁面需要做一定的監(jiān)控,而最可行的前端監(jiān)控方式就是將頁面的日志選擇上報到監(jiān)控日志服務器中。
前端日志上報可以很簡單
對業(yè)務邏輯的執(zhí)行收集了日志數據之后可以參數的形式構造一個url,再通過一個Image請求發(fā)送到到服務器就完成了日志的上報。
- (new Image).src = `/r.png?page=${location.href}¶m1=${param1}...`;
這樣一行代碼就搞定了日志的上報,然鵝,在生產環(huán)境中,日志上報所延伸的問題要復雜很多。
日志上報帶來的問題
日志上報最終是為了服務業(yè)務,監(jiān)控業(yè)務的運行狀態(tài),一般而言前端運行的場景中開發(fā)者最期望監(jiān)控的不外乎頁面&API請求是否正常響應和頁面js邏輯是否正常執(zhí)行。
為了覆蓋這兩個監(jiān)控目標,需要通過很多類型的日志來覆蓋,還有一些特殊場景下,開發(fā)者還希望能與具體業(yè)務靈活結合,實現自定義上報。所以常見的日志類型如下
– 頁面&API請求是否正常響應
– API調用日志 – API調用成功與否及其耗時
– 頁面性能日志 – 頁面連接耗時、首次渲染時間、資源加載耗時等
– 訪問統計日志 – PV/UV,短時間內斷崖式的量變化很容易反應問題
– 頁面js邏輯是否正常執(zhí)行
– 頁面穩(wěn)定性日志 – 頁面加載和頁面交互產生的js error信息
– 業(yè)務相關日志
– 自定義上報 – 某些業(yè)務邏輯的結果、速度、統計值等自定義內容
隨著前端業(yè)務的壯大,日志監(jiān)控上報的量會快速增加,監(jiān)控的邏輯也會越來越復雜,而在生產環(huán)境中,前端監(jiān)控的最基本原則是日志獲取和上報本身不能拋出異?;蛘哂绊戫撁嫘阅堋?/p>
這么多的日志類型代表了日志獲取的邏輯復雜,同時各種各樣的瀏覽器和環(huán)境會讓這個問題變得更棘手,例如想用console.warn打印異常信息,但是可能會出現warn函數調用報錯;例如捕捉到了error但是error.message全是Script error.…
瀏覽器的兼容性,前端業(yè)務邏輯依賴,日志上報方式,日志上報效率,用戶操作習慣,網絡環(huán)境等因素都可能讓日志上報產生問題甚至影響業(yè)務。這些因素會給日志上報帶來可靠和性能兩方面的問題
日志上報的可靠性問題
瀏覽器兼容性
在不同端和瀏覽器中,因為兼容性的不同,日志獲取邏輯的和上報方法需要兼容多種方式來進行,例如fetch方法方法是否可用,頁面性能(performance)計算是否可以使用NT2標準,這些問題可能會帶來上報邏輯本身報錯污染業(yè)務日志統計;
上報可靠性
日志采集sdk可能因為網絡原因無法加載,所以安全的方式是sdk注入的位置合理的靠后,那么頁面打開到sdk初始化這段時間就會產生漏報;
后端為了業(yè)務分離,通常會獨立設定一個日志采集服務器,這種情況下日志上報可能會遇到跨域問題;
用戶的頻繁操作和關閉頁面會可能造成很多已經收集的數據漏報。
日志上報的性能問題
在一個復雜站點中,這些日志數據可能會非常多,上報可能會因為瀏覽器并發(fā)數量的限制阻塞業(yè)務的網絡請求,或者影響頁面性能。
更優(yōu)雅的上報姿勢
姿勢一 隔離業(yè)務
資源隔離
為了避免影響業(yè)務,那么理所當然,為了不占用業(yè)務計算資源,日志上報需要單獨設定后端服務。
同時也不能使用與業(yè)務相同的域名,這跟頁面盡量使用CDN引入資源的原理相似,瀏覽器會對同一個域名有一定的并發(fā)數限制。
而頁面性能、資源加載、初始化API、PV/UV、初始化js邏輯錯誤等日志都是頁面初始化的時候觸發(fā)上報,這種短時間大量的上報可能會造成網絡請求延時。例如chrome對同一個域名的最大并發(fā)連接數為6個,如果日志同時上報了6次以上,就會對同域名的業(yè)務造成影響;更壞的情況如頁面有一些錯誤、網絡連接質量質量不高會讓日志上報阻礙頁面渲染。
因此日志上報可以像使用CDN服務一樣,使用單獨域名和日志處理服務。
既然使用了不同的域名,那么跨域問題隨之而來,這需要前后端共同支持。服務器需要允許外部訪問Access-Control-Allow-Origin:*;前端在進行日志上報的時候要添加避免跨域標識,如fetch方式:
- var url = 'https://arms-retcode.aliyuncs.com/r.png';
- fetch(
- `${url}?t=perf&page=qar.alibaba-inc.com&load=1168`,
- {mode:'no-cors'}
- )
不同域名一個性能缺點是增加首次DNS解析時間,不過可以通過在頁面添加DNS預解析來避免。
- <link rel="dns-prefetch" href="https://arms-retcode.aliyuncs.com">
異常隔離
在資源隔離的基礎上,日志上報的異常處理也需要隔離,日志本身拋出的異常絕對不能和業(yè)務異?;煸谝黄鹕蠄蟆?/p>
進行充分測試的前提下,最簡單粗暴的方式是在整個監(jiān)控sdk外面添加try...catch...,好處是永遠不會出現sdk本身錯誤上報,不過同時也讓開發(fā)者失去了發(fā)現sdk問題的途徑。所以兩者兼得的方式是必要的。
這里提供一個關鍵模塊埋點的方法,它對整個前端監(jiān)控sdk多個關鍵點上埋點并且收集的結果中只標記是否成功。話不多說,直接上示例代碼:
- // 全局標記匯總,初始化為36個點位均為1的數組
- var N = 36;
- var sdkStat = Array.from({length: N}, () => 1);
- /** 日志上報功能模塊
- * 對應模塊報錯設置對應點位為0,多個點位為0可以幫助找到錯誤發(fā)生鏈路
- */
- try{/* sdk module 0*/}catch(){sdkStat[0] = 0;}
- /* other modules */
- try{/* sdk module 35*/}catch(){sdkStat[35] = 0;}
- // 日志上報發(fā)送模塊
- var statStr = parseInt(sdkStat.join(''),2).toString(36);
- (new Image).src = `/r.png?¶m1=${param1}&sdkStat=${statStr}...`;
姿勢二 壓縮請求響應報文
壓縮之前重新審視一下(new Image).src的日志發(fā)送方式:
HTTP Request: 前端日志數據以多組key=value的字符串形式接在一個Image資源請求的url后面,前端發(fā)送Image請求。
HTTP Responce: 服務器返回響應結果或者空圖片。
日志數據直接放到url中的好處是網絡傳輸效率高。然而url長度是有限制的,例如IE瀏覽器是2083個字符,同時服務器也會對url長度進行限制。
類似如下的js error信息就沒有辦法完整上報,
- $ is not defined@https://www.example.com.cn/catalog/?spm=a2o4k.customer.0.0.37c1379dmQwdrW&q=pediasure&searchclickposition=hint:3:231
- ...
- Tg@https://www.googletagmanager.com/gtm.js?id=GTM-KTVS7D9&l=shadowDatalayerKi7l:64:32
- ...
不僅僅是js error的錯誤棧深還因為urlencode對特殊字符和漢字的轉碼,這兩個因素會使url長度輕松突破限制。
另外業(yè)務邏輯實際上不關注而且也應關注日志上報的響應結果,所以這個請求的結果應該盡可能省去。
針對報文壓縮有以下方式:
HTTP/2頭部壓縮
http請求中,每次請求都會傳輸一系列的請求頭來描述請求的資源及其特性,然而實際上每次請求都有很多相同的值,如Host:,user-agent:,Accept等。這些數據能夠占用到300-800byte的傳輸量,如果攜帶大的cookie,請求頭甚至可以占用1kb的空間,而實際真正需要上報的日志數據僅僅只有10~50byte的大小。在HTTP 1.x中,每次日志上報請求頭都攜帶了大量的重復數據導致性能浪費。
HTTP/2頭部壓縮采用Huffman Code壓縮請求頭,并用動態(tài)表更新每次請求不同的數據來把每次請求的頭部壓縮到很小。
HTTP/1.1效果
HTTP/2.0效果
頭部壓縮后每條日志請求的size都大大減小,響應的速度也有提升。
壓縮日志的長度
最需要壓縮即js error的錯誤棧,錯誤棧當中占位最多是錯誤定位的文件地址,而很多錯誤棧有很多相同的文件,壓縮空間就來源于stack中js文件的url重復。
一個典型的jserror stack經常會出現這種形式如下:
- obj0.fn0 at (http://loooooooooonnnnnnnnnnng/loooooong/long.js 123:1)
- obj1.fn1 at (http://loooooooooonnnnnnnnnnng/loooooong/long.js 234:1)
- obj2.fn2 at (http://loooooooooonnnnnnnnnnng/laaaaaang/lang.js 345:1)
- ...
可考慮把文件url抽取出來單獨作為一個字典,那么上報內容可縮減為
- files={'f1':'http://loooooooooonnnnnnnnnnng/loooooong/long.js','f2':'...'}
- obj0.fn0 at (f1 123:1)
- obj1.fn1 at (f1 234:1)
- obj2.fn2 at (f2 345:1)
- ...
即可大大縮減日志長度。
省去響應體
日志上報本身只關注日志有沒有上報,而對上報請求的返回內容并不關注,甚至完全可以不需要返回內容。所以使用HTTP HEAD的方式上報,并且返回的響應體為空,避免響應體傳輸資源損耗。
這時候只需要設置一個nginx服務器來記錄日志內容并返回200狀態(tài)碼即可。
- fetch(`${url}?t=perf&page=lazada-home&load=1168`,
- {mode:'no-cors',method:'HEAD'}
- )
姿勢三 合并上報
既然一個頁面上報的次數那么多,一個更容易想到的idea應該是把日志合并上報來減小請求數量。
HTTP/2多路復用
用戶瀏覽器和日志服務器之間產生多次HTTP請求,而在HTTP/1.1 Keep-Alive下,日志上報會以串行的方式傳輸,會讓后面的日志上報延時。通過HTTP/2的多路復用來合并上報,節(jié)省網絡連接的開銷。
HTTP POST合并
POST請求因為request body可以有更大施展空間,在HTTP POST中只要一次包含多條日志的內容,那么相對于一條日志一次HTTP HEAD請求的方式會更加經濟。
在HTTP POST的基礎上,可以順便解決用戶關掉或者切換頁面造成的漏報問題。
以前常見的解決方式是監(jiān)聽頁面的unload或者beforeunload事件,并以通過同步的XMLHttpRequest請求或者構造一個特定src的<img>標簽來延遲上報。
- window.addEventListener("unload", uploadLog, false);
- function uploadLog() {
- var xhr = new XMLHttpRequest();
- xhr.open("POST", "/r.png", false); // false表示同步
- xhr.send(logData);
- }
這種上報的缺點是會給下一個頁面的性能造成影響。更優(yōu)雅的方式是使用navigator.sendBeacon(),它能夠異步地發(fā)送日志數據。
- window.addEventListener("unload", uploadLog, false);
- function uploadLog() {
- navigator.sendBeacon("/r.png", logData);
- }
合并前
合并后(navigator.sendBeacon)
理想情況下,合并n個日志上報耗費的總時間能達到原來的1/n
小結
前端業(yè)務場景和瀏覽器的兼容性千差萬別,所以日志上報要兼容多種方式;頁面生命周期、業(yè)務邏輯影響了日志是否可獲取和是否漏報,所以對應的日志類型和上報時機要嚴格把握;前端業(yè)務邏輯快速迭代且場景多樣,所以日志上報要做到與業(yè)務解耦合同時可以自定義上報…
這些大大小小的坑促使我們把前端日志監(jiān)控沉淀為一個獨立且系統性的工程來做,在打磨這個工程的過程中我們同樣還在探索是否有更加高效、穩(wěn)定的日志上報方式。