Skip to content

前端监控及上报

作者:Atom
字数统计:5.7k 字
阅读时长:21 分钟

前端业务越来越复杂的今天,即便本地做各种各样充分的测试,依照 caniuse 把兼容性也一一处理,依然无法保证页面完全正常运行, 同时我们也不清楚运行的状况。 前端的页面跟设备、浏览器、网络环境、用户操作习惯等等各种各样的因素密切相关。因此前端监控并上报日志到日志服务器是保证快速收集和处理问题的必要手段。

  • 页面在用户那里运行,如果 10%的用户页面出现问题而自己本地没有办法重现?

  • 如何先一步了解到前端出现的问题,而不是等用户反馈?

  • 能不能像查看服务端日志一样来定位前端页面运行的问题?

国内监控平台

国内常用的监控平台下面几个还不错, 主要缺点就是价格了...

  • sentry :从监控错误、错误统计图表、多重标签过滤和标签统计到触发告警,这一整套都很完善,团队项目需要充钱,而且数据量越大钱越贵
  • fundebug:除了监控错误,还可以录屏,也就是记录错误发生的前几秒用户的所有操作,压缩后的体积只有几十 KB,但操作略微繁琐
  • webfunny:也是含有监控错误的功能,可以支持千万级别日 PV 量,额外的亮点是可以远程调试、性能分析,也可以 docker 私有化部署(免费),业务代码加密过

常见的日志分类

  • 页面及 API 请求和响应状态(成功与否)及响应时长
  • 页面性能日志(页面连接耗时、首次渲染时间、资源加载耗时等)
  • 页面行为日志(PV/UV 等等)
  • 页面错误日志(JS 执行情况/JS 错误情况/网页崩溃)
  • 页面业务日志
  • 页面自定义日志

网络接口日志

如何监控前端接口请求呢

一般前端请求都是用 jquery 的 ajax 请求,也有用 fetch 请求的,以及前端框架自己封装的请求等等。总之他们封装的方法各不相同,但是万变不离其宗,他们都是对浏览器的这个对象 window.XMLHttpRequest 进行了封装,所以我们只要能够监听到这个对象的一些事件,就能够把请求的信息分离出来。

如何监听 ajax 请求

如果你用的 jquery、zepto、或者自己封装的 ajax 方法,就可以用如下的方法进行监听。我们监听 XMLHttpRequest 对象的两个事件 loadstart, loadend。但是监听的结果并不是像我们想象的那么容易理解,我们先看下 ajaxLoadStart,ajaxLoadEnd 的回调方法。

js
/**
 * 页面接口请求监控
 */
function recordHttpLog() {
  // 监听ajax的状态
  function ajaxEventTrigger(event) {
    var ajaxEvent = new CustomEvent(event, {
      detail: this
    })
    window.dispatchEvent(ajaxEvent)
  }
  var oldXHR = window.XMLHttpRequest
  function newXHR() {
    var realXHR = new oldXHR()
    realXHR.addEventListener(
      'loadstart',
      function () {
        ajaxEventTrigger.call(this, 'ajaxLoadStart')
      },
      false
    )
    realXHR.addEventListener(
      'loadend',
      function () {
        ajaxEventTrigger.call(this, 'ajaxLoadEnd')
      },
      false
    )
    // 此处的捕获的异常会连日志接口也一起捕获,如果日志上报接口异常了,就会导致死循环了。
    // realXHR.onerror = function () {
    //   siftAndMakeUpMessage("Uncaught FetchError: Failed to ajax", WEB_LOCATION, 0, 0, {});
    // }
    return realXHR
  }
  var timeRecordArray = []
  window.XMLHttpRequest = newXHR
  window.addEventListener('ajaxLoadStart', function (e) {
    var tempObj = {
      timeStamp: new Date().getTime(),
      event: e
    }
    timeRecordArray.push(tempObj)
  })
  window.addEventListener('ajaxLoadEnd', function () {
    for (var i = 0; i < timeRecordArray.length; i++) {
      if (timeRecordArray[i].event.detail.status > 0) {
        var currentTime = new Date().getTime()
        var url = timeRecordArray[i].event.detail.responseURL
        var status = timeRecordArray[i].event.detail.status
        var statusText = timeRecordArray[i].event.detail.statusText
        var loadTime = currentTime - timeRecordArray[i].timeStamp
        if (!url || url.indexOf(HTTP_UPLOAD_LOG_API) != -1) return
        var httpLogInfoStart = new HttpLogInfo(
          HTTP_LOG,
          url,
          status,
          statusText,
          '发起请求',
          timeRecordArray[i].timeStamp,
          0
        )
        httpLogInfoStart.handleLogInfo(HTTP_LOG, httpLogInfoStart)
        var httpLogInfoEnd = new HttpLogInfo(
          HTTP_LOG,
          url,
          status,
          statusText,
          '请求返回',
          currentTime,
          loadTime
        )
        httpLogInfoEnd.handleLogInfo(HTTP_LOG, httpLogInfoEnd)
        // 当前请求成功后就在数组中移除掉
        timeRecordArray.splice(i, 1)
      }
    }
  })
}

一个页面上会有很多个请求,当一个页面发出多个请求的时候,ajaxLoadStart 事件被监听到,但是却无法区分出来到底发送的是哪个请求,只返回了一个内容超多的事件对象,而且事件对象的内容几乎完全一样。当 ajaxLoadEnd 事件被监听到的时候,也会返回一个内容超多的时间对象,这个时候事件对象里包含了接口请求的所有信息。幸运的是,两个对象是同一个引用,也就意味着,ajaxLoadStart 和 ajaxLoadEnd 事件被捕获的时候,他们作用的是用一个对象。那我们就有办法分析出来了。

当 ajaxLoadStart 事件发生的时候,我们将回调方法中的事件对象全都放进数组 timeRecordArray 里,当 ajaxLoadEnd 发生的时候,我们就去遍历这个数据,遇到又返回结果的事件对象,说明接口请求已经完成,记录下来,并从数组中删除该事件对象。这样我们就能够逐一分析出接口请求的内容了。

如何监听 fetch 请求

通过第一种方法,已经能够监听到大部分的 ajax 请求了。然而,使用 fetch 请求的人越来越多,因为 fetch 的链式调用可以让我们摆脱 ajax 的嵌套地狱,被更多的人所青睐。奇怪的是,我用第一种方式,却无法监听到 fetch 的请求事件,这是为什么呢?

js
return new Promise(function(resolve, reject) {
  var request = new Request(input, init);
  var xhr = new XMLHttpRequest();

  xhr.onload = function() {
    var options = {
      status: xhr.status,
      statusText: xhr.statusText,
      headers: parseHeaders(xhr.getAllResponseHeaders() || "")
    };
    options.url =
      "responseURL" in xhr
        ? xhr.responseURL
        : options.headers.get("X-Request-URL");
    var body = "response" in xhr ? xhr.response : xhr.responseText;
    resolve(new Response(body, options));
  };

  xhr.send(
    typeof request.\_bodyInit === "undefined" ? null : request.\_bodyInit
  );
});

这个是 fetch 的一段源码, 可以看到,它创建了一个 Promise, 并新建了一个 XMLHttpRequest 对象 var xhr =newXMLHttpRequest()。由于 fetch 的代码是内置在浏览器中的,它必然先用监控代码执行,所以,我们在添加监听事件的时候,是无法监听 fetch 里边的 XMLHttpRequest 对象的。怎么办呢,我们需要重写一下 fetch 的代码。只要在监控代码执行之后,我们重写一下 fetch,就可以正常监听使用 fetch 方式发送的请求了。就这么简单 :)

看一下需要监听的字段:

js
function setCommonProperty() {
 this.happenTime = new Date().getTime();
 this.webMonitorId = WEB\_MONITOR\_ID;
 this.simpleUrl = window.location.href.split("?")\[0\].replace("#", "");
 this.completeUrl = utils.b64EncodeUnicode(
 encodeURIComponent(window.location.href)
 );
 this.customerKey = utils.getCustomerKey();

 var wmUserInfo = localStorage.wmUserInfo
 ? JSON.parse(localStorage.wmUserInfo)
 : "";
 this.userId = utils.b64EncodeUnicode(wmUserInfo.userId || "");
 this.firstUserParam = utils.b64EncodeUnicode(
 wmUserInfo.firstUserParam || ""
 );
 this.secondUserParam = utils.b64EncodeUnicode(
 wmUserInfo.secondUserParam || ""
 );
}

function HttpLogInfo(
 uploadType,
 url,
 status,
 statusText,
 statusResult,
 currentTime,
 loadTime
) {
 setCommonProperty.apply(this);
 this.uploadType = uploadType;
 this.httpUrl = utils.b64EncodeUnicode(encodeURIComponent(url));
 this.status = status;
 this.statusText = statusText;
 this.statusResult = statusResult;
 this.happenTime = currentTime;
 this.loadTime = loadTime;
}

所有工作准备完毕,如果把收集到的日志从不同的维度展现出来,我就不细说了,直接上图了。如此,便能够对前端接口报错的情况有一个清晰的了解,也能够快速的发现线上的问题。

性能数据采集

Performance

Performance 接口可以获取到当前页面中与性能相关的信息,它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。

其中采集性能数据, 用到的 API 是window.performance.timing, 这里用两张图来展示对应关系吧

performance-api.webpperformance-level.webp

为了方便展示, 我这里再用一个对象来展示:

js
timing: {
 navigationStart: 1543806782096,

 unloadEventStart: 1543806782523,

 unloadEventEnd: 1543806782523,

 redirectStart: 0,

 redirectEnd: 0,

 fetchStart: 1543806782096,

 domainLookupStart: 1543806782096,

 domainLookupEnd: 1543806782096,

 connectStart: 1543806782099,

 connectEnd: 1543806782227,

 secureConnectionStart: 1543806782162,

 requestStart: 1543806782241,

 responseStart: 1543806782516,

 responseEnd: 1543806782537,

 domLoading: 1543806782573,

 domInteractive: 1543806783203,

 domContentLoadedEventStart: 1543806783203,

 domContentLoadedEventEnd: 1543806783216,

 domComplete: 1543806783796,

 loadEventStart: 1543806783796,

 loadEventEnd: 1543806783802
}

我们常用到的计算公式:

sh
redirect: timing.redirectEnd - timing.redirectStart,

dom: timing.domComplete - timing.domLoading,

load: timing.loadEventEnd - timing.navigationStart,

unload: timing.unloadEventEnd - timing.unloadEventStart,

request: timing.responseEnd - timing.requestStart,

time: new Date().getTime(),

白屏时间计算

还有一个比较重要的时间就是白屏时间,它指从输入网址,到页面开始显示内容的时间。

将以下脚本放在 </head>前面就能获取白屏时间。

html
<script>
  whiteScreen = new Date() - performance.timing.navigationStart
  whiteScreen =
    performance.timing.domLoading - performance.timing.navigationStart
</script>

资源加载时间计算

通过  window.performance.getEntriesByType('resource')  这个方法,我们还可以获取相关资源(js、css、img...)的加载时间,它会返回页面当前所加载的所有资源。

performance-resource

它一般包括以下几个类型:

  • sciprt
  • link
  • img
  • css
  • fetch
  • other
  • xmlhttprequest

我们只需用到以下几个信息:

md
name: item.name,duration: item.duration.toFixed(2),size: item.transferSize,protocol: item.nextHopProtocol,

现在,写几行代码来收集这些数据。

js
const getPerformance = () => {
  if (!window.performance) return
  const { timing } = window.performance
  const performance = {
    redirect: timing.redirectEnd - timing.redirectStart,
    whiteScreen,
    dom: timing.domComplete - timing.domLoading,
    load: timing.loadEventEnd - timing.navigationStart,
    unload: timing.unloadEventEnd - timing.unloadEventStart,
    request: timing.responseEnd - timing.requestStart,
    time: new Date().getTime()
  }
  return performance
}
const getResources = () => {
  if (!window.performance) return
  const data = window.performance.getEntriesByType('resource')
  const resource = {
    xmlhttprequest: [],
    css: [],
    other: [],
    script: [],
    img: [],
    link: [],
    fetch: [],
    time: new Date().getTime()
  }
  data.forEach((item) => {
    const arry = resource[item.initiatorType]
    arry &&
      arry.push({
        name: item.name,
        duration: item.duration.toFixed(2),
        size: item.transferSize,
        protocol: item.nextHopProtocol
      })
  })
  return resource
}

用户行为日志

单纯收集错误信息是可以提高错误定位的效率,但如果再配合上用户行为的话就锦上添花,定位错误的效率再上一层,如下图所示,可以清晰的看到用户做了哪些事:进了哪个页面 => 点击了哪个按钮 => 触发了哪个接口:

breadcrumb

用户行为前端页面展示

DOM 事件信息

dom事件获取包括很多:clickinputdoubleClick等等,一种直接在 window 上面监听 click 事件(注意第三个参数为true):

js
window.addEventListener('click', function (e) {}, true)

还有一种是通过重写window.addEventListener的方式来截取开发者对 dom 的监听事件。

路由切换信息

在单页应用中有两种路由变换:hashchangehistory

  • history

当浏览器支持history模式时,会被以下两个事件所影响:pushStatereplaceState,且这两个事件不会触发onpopstate的回调,所以我们需要监听这个三个事件:

onpopstate

onpopstate 重写

  • hashchange

当浏览器只支持hashchange时,就需要重写 hashchange:

hashchange

hashchange 重写

console 信息

正常情况下正式环境是不应该有console的,那为什么要收集console的信息?第一:非正常情况下,正式环境或预发环境也可能会有console,第二:很多时候也可以把sdk放入测试环境上面调试。所以最终还是决定收集console信息,但是在初始化的时候的传参来告诉sdk是否监听console的信息收集。

relaceConsole console 重写

SPA 应用 Hack

window.performance API 是有缺点的,在 SPA 切换路由时,window.performance.timing  的数据不会更新。所以我们需要另想办法来统计切换路由到加载完成的时间。拿 Vue 举例,一个可行的办法就是切换路由时,在路由的全局前置守卫  beforeEach  里获取开始时间,在组件的  mounted  钩子里执行  vm.$nextTick  函数来获取组件的渲染完毕时间。

js
router.beforeEach((to, from, next) => {
  store.commit('setPageLoadedStartTime', new Date())
})
js
mounted() { this.$nextTick(() => {
  this.$store.commit('setPageLoadedTime',
  new Date() - this.$store.state.pageLoadedStartTime)
})}

用户信息收集

使用 window.navigator 可以收集到用户的设备信息,操作系统,浏览器信息...

UV(Unique visitor)

是指通过互联网浏览这个网页的访客,00:00-24:00 内相同的设备访问只被计算一次。一天内同个访客多次访问仅计算一个 UV。

在用户访问网站时,可以生成一个随机字符串 + 时间日期,保存在本地。在网页发生请求时(如果超过当天 24 小时,则重新生成),把这些参数传到后端,后端利用这些信息生成 UV 统计报告。

PV(Page View)

即页面浏览量或点击量,用户每 1 次对网站中的每个网页访问均被记录 1 个 PV。用户对同一页面的多次访问,访问量累计,用以衡量网站用户访问的网页数量。

页面停留时间

  • 传统网站

用户在进入 A 页面时,通过后台请求把用户进入页面的时间捎上。过了 10 分钟,用户进入 B 页面,这时后台可以通过接口捎带的参数可以判断出用户在 A 页面停留了 10 分钟。

  • SPA

可以利用 router 来获取用户停留时间,拿 Vue 举例,通过  router.beforeEachdestroyed  这两个钩子函数来获取用户停留该路由组件的时间。

  • 浏览深度

通过  document.documentElement.scrollTop  属性以及屏幕高度,可以判断用户是否浏览完网站内容。

  • 页面跳转来源

通过  document.referrer  属性,可以知道用户是从哪个网站跳转而来。有默认两种情况不携带 referrer(https->跳到 http / 直接打开资源无 referrer)。

而浏览器也支持改变默认的referrer的行为:

I. 对于开发者来说,rel="noreferrer"属性是最简单的一种方法。<a><area><form>三个标签可以使用这个属性,一旦使用,该元素就不会发送 Referer 字段。

html
<a href="..." rel="noreferrer" target="_blank">xxx</a>

上面链接点击产生的 HTTP 请求,不会带有 Referer 字段。

II. 使用Referrer Policy:

第一种使用方式:

在 HTTP 的头信息中添加Referrer-Policy: origin

第二种使用方式:

html
<meta name="referrer" content="origin" />

第三种使用方式:

<a><area><img><iframe><link>标签,可以设置 referrerpolicy 属性。

html
<a href="..." referrerpolicy="origin" target="_blank">xxx</a>

错误崩溃日志

目前所能捕捉的错误有三种:

  • 资源加载错误: 通过  addEventListener('error', callback, true)  在捕获阶段捕捉资源加载失败错误。

  • JS 执行错误: 通过  window.onerror  捕捉 JS 错误。

  • Promise 错误: 通过  addEventListener('unhandledrejection', callback)捕捉 promise 错误,但是没有发生错误的行数,列数等信息,只能手动抛出相关错误信息。

  • 前端框架类错误: 前端框架如 Angule/Vue/React 的内部错误

  • 页面崩溃错误: 在页面异常退出时的错误

我们可以建一个错误数组变量  errors  在错误发生时,将错误的相关信息添加到数组,然后在某个阶段统一上报,具体如何操作请看下面的代码:

js
const monitor = []
// 静态资源异常监听, 需在捕获阶段获取(请求资源不会冒泡到window)
document.addEventListener(
  'error',
  (e) => {
    const { target } = e
    if (target !== window) {
      monitor.errors.push({
        type: target.localName,
        url: target.src || target.href,
        msg: `${target.src || target.href} is load error`,
        time: new Date().getTime()
      })
    }
  },
  true
)
// 普通js错误
window.onerror = function (msg, url, row, col, error) {
  monitor.errors.push({
    type: 'javascript',
    row,
    col,
    msg: error && error.stack ? error.stack : msg,
    url,
    time: new Date().getTime()
  })
}
// promise错误
document.addEventListener('unhandledrejection', (e) => {
  monitor.errors.push({
    type: 'promise',
    msg: (e.reason && e.reason.msg) || e.reason || '',
    time: new Date().getTime()
  })
})

// 跨域脚本错误 <script src="http://xxx.lorain/main.js" crossorigin></script>,我们为 script 标签添加 crossOrigin 属性。
// 或者动态去添加 js 脚本
// const script = document.createElement("script");
// script.crossOrigin = "anonymous";
// script.src = url;
// document.body.appendChild(script);

// 框架错误
// Vue异常监听, 7通过, 捕获vue框架的全局错误
Vue.config.errorHandler = (err, vm, info) => {
  console.error('通过vue errorHandler捕获的错误')
  console.error(err)
  console.error(vm)
  console.error(info)
}

// React错误监听, 在全局应用中使用错误监听组件来包裹
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props)
    this.state = { hasError: false }
  }

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true })
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info)
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>
    }
    return this.props.children
  }
}

// 崩溃监听, 监听页面是否正常卸载,一般在service worker中监听
window.addEventListener('load', function () {
  sessionStorage.setItem('good_exit', 'pending')
  setInterval(function () {
    sessionStorage.setItem('time_before_crash', new Date().toString())
  }, 1000)
})

window.addEventListener('beforeunload', function () {
  sessionStorage.setItem('good_exit', 'true')
})

if (
  sessionStorage.getItem('good_exit') &&
  sessionStorage.getItem('good_exit') !== 'true'
) {
  /* insert crash logging code here */
}

// 在一般的崩溃发生后, 用户会强杀脚本, 导致监听异常
// 基于以下原因,我们可以使用 Service Worker 来实现网页崩溃的监控:
// Service Worker 有自己独立的工作线程,与网页区分开,网页崩溃了,Service Worker 一般情况下不会崩溃;
// Service Worker 生命周期一般要比网页还要长,可以用来监控网页的状态;
// 网页可以通过 navigator.serviceWorker.controller.postMessage API 向掌管自己的 SW 发送消息。

// 以下是sdk中的崩溃监听心跳代码
const worker = new ServiceWorker('xxxxxx.js')
const CHECK_CRASH_INTERVAL = 10 * 1000
// 每 10s 检查一次
const CRASH_THRESHOLD = 15 * 1000
// 15s 超过15s没有心跳则认为已经 crash
const pages = {}
let timer
function checkCrash() {
  const now = Date.now()
  for (const id in pages) {
    const page = pages[id]
    if (now - page.t > CRASH_THRESHOLD) {
      // 上报 crash
      delete pages[id]
    }
  }
  if (Object.keys(pages).length === 0) {
    clearInterval(timer)
    timer = null
  }
}
worker.addEventListener('message', (e) => {
  const { data } = e
  if (data.type === 'heartbeat') {
    pages[data.id] = { t: Date.now() }
    if (!timer) {
      timer = setInterval(() => {
        checkCrash()
      }, CHECK_CRASH_INTERVAL)
    }
  } else if (data.type === 'unload') {
    delete pages[data.id]
  }
})

崩溃监听中service-worker中的代码

js
if (navigator.serviceWorker.controller !== null) {
  let HEARTBEAT_INTERVAL = 5 * 1000
  // 每五秒发一次心跳
  let sessionId = uuid()
  let heartbeat = function () {
    navigator.serviceWorker.controller.postMessage({
      type: 'heartbeat',
      id: sessionId,
      data: {}

      // 附加信息,如果页面 crash,上报的附加数据
    })
  }
  window.addEventListener('beforeunload', function () {
    navigator.serviceWorker.controller.postMessage({
      type: 'unload',
      id: sessionId
    })
  })
  setInterval(heartbeat, HEARTBEAT_INTERVAL)
  heartbeat()
}

其他日志

这块的日志根据业务具体来定制

如何上报

收集了日志数据之后通过构造一个带参数 URL, 再通过一个 Image 请求发送到到服务器就完成了日志的上报。

js
new Image().src = `/log.gif?page=${location.href}&param=${param}...`

远端接口可以设计为如下形式:

ts
const express = require('express');
const app = express();

// 透明单像素 GIF 数据
const transparentGifBuffer = Buffer.from([
  0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a 文件头
  0x01, 0x00, 0x01, 0x00, 0x80, 0xff, 0x00,
  0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x21,
  0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00,
  0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
  0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01,
  0x00, 0x3b
]);

// 创建上报接口
app.get('/log.gif', (req, res) => {
  // 将请求数据记录到日志或数据库中
  console.log('收到监控数据:', req.query);

  // 返回透明 GIF 图片
  res.writeHead(200, {
    'Content-Type': 'image/gif',
    'Content-Length': transparentGifBuffer.length
  });
  res.end(transparentGifBuffer);
});

// 启动服务
app.listen(3000, () => {
  console.log('监控上报服务启动于 http://localhost:3000/log.gif');
});

为何使用gif上报数据

  • 跨域限制少:大部分浏览器不会对 image 标签或 img.src 请求的资源进行跨域限制,因此可以直接向不同源发送监控数据,而不需要设置跨域头。
  • 减少干扰:相比 AJAX 请求,通过 img 标签加载 GIF 的方式不容易被误认为是业务请求,对业务请求的统计或分析影响较小。
  • 兼容性高:img 标签发起请求方式简单且能在大部分浏览器和场景下执行成功,特别是一些不支持 AJAX 的场景。
  • 缓存控制:可以通过添加时间戳参数,确保请求不会被浏览器缓存。
  • 体积因素: 最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节。

上报的措施

在日志的上报过程中, 需要考虑到的主要是:

  • 日志上报可靠性: 设计到浏览器的兼容性,网络未加载成功 SDK 等
  • 日志上报的性能: 日志数据可能会非常多,上报可能会因为浏览器并发数量的限制阻塞业务的网络请求,或者影响页面性能

综上的问题, 日志上报的手段主要有以下几种

1. 隔离业务上报

一、为了不占用业务计算资源,日志上报需要单独设定后端服务

二、浏览器会对同一个域名有一定的并发数限制,日志上报不能使用与业务相同的域名

三、不同域名导致跨域, 需要前后端共同支持

服务器需要允许外部访问 Access-Control-Allow-Origin:*;前端在进行日志上报的时候要添加避免跨域标识

四、域名不同带来的 DNS 解析延迟,因此需要使用域名预解析

html
<link rel="dns-prefetch" href="https://arms-retcode.aliyuncs.com" />

五、日志上报也可能出现异常, 因此也需要同业务分开

日志本身抛出的异常绝对不能和业务异常混在一起上报

进行充分测试的前提下,最简单粗暴的方式是在整个监控 sdk 外面添加 try...catch..., 好处是永远不会出现 sdk 本身错误上报,不过同时也让开发者失去了发现 sdk 问题的途径。所以两者兼得的方式是必要的。

2. 压缩请求和响应报文

在上面提到使用图片及 URL 参数来上报日志, 其实这个方案也有他的缺陷:

长度限制: 日志上报通过 URL 参数传递,URL 长度是有限制的

因此, 可以采用 HTTP2 的头部压缩, JS Error错误栈信息应该使用字符串来保存相同部分, 压缩空间就来源于 stack 中 js 文件的 url 重复。

一个典型的 jserror stack 经常会出现这种形式如下:

sh
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 抽取出来单独作为一个字典,那么上报内容可缩减为

sh
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 状态码即可。

js
fetch(`${url}?t=perf&page=lazada-home&load=1168`, {
  mode: 'no-cors',
  method: 'HEAD'
})

3. 合并上报

页面上报的次数那么多,我们应该是把日志合并上报来减小请求数量

开启 Http2, 使用 HTTP/2 的多路复用来合并上报

用户浏览器和日志服务器之间产生多次 HTTP 请求,而在 HTTP/1.1 Keep-Alive 下,日志上报会以串行的方式传输,会让后面的日志上报延时。通过 HTTP/2 的多路复用来合并上报,节省网络连接的开销。

HTTP POST 合并

在 HTTP POST 中只要一次包含多条日志的内容,那么相对于一条日志一次 HTTP HEAD 请求的方式会更加经济; 其次需要解决用户关掉或者切换页面造成的漏报问题

常见的方案主要是unload或者beforeUnload事件中进行上报, 上报可以使用同步上报或者navigation.setBeacon进行异步上报

js
// 同步上报
window.addEventListener('unload', uploadLog, false)

function uploadLog() {
  var xhr = new XMLHttpRequest()

  xhr.open('POST', '/xx.png', false) // false表示同步

  xhr.send(logData)
}
js
// 异步上报
window.addEventListener('unload', uploadLog, false)

function uploadLog() {
  navigator.sendBeacon('/xx.png', logData)
}

合并前:

合并前

合并后:

合并后

参考内容