网站运行数据的采集 | 字节青训营项目总结

564 阅读7分钟

前言

本人参与了第四届字节跳动青训营,担任一个6人小组的组长,我们组选择开发项目是网站监控系统

这个选题最难的部分应该就是网站各种数据的采集了。目前,开发任务已经完成,活动也临近尾声,抽空将这部分内容整理,分享出来。

采集要求

我们采集网站页面的关键运行数据,主要包括以下4个方面

  1. 异常报错监控:包括脚本异常、资源异常、接口异常、白屏异常
  2. 页面性能监控:如 DNS、FP、FCP、Dom Ready 等
  3. 用户行为监控:PV/UV、页面停留时间等
  4. 网络请求监控:请求路径、成功率、返回信息等

接下来我将逐个介绍这些参数是如何采集的

网络请求监控

本来应该先从异常报错开始讲的,但由于其中要采集接口异常,这需要通过封装网络请求来实现的,所以还是从网路请求开始讲吧

浏览器发出的请求有很多,我们这里只监听 Fetch/XHR 类型的请求。我们要采集的数据有:请求发出的时间、发送请求的页面路径、请求的路径、请求方式、响应状态码、响应耗时、响应信息

重写 XMLHttpRequest

拦截 XMR 请求有两种方式,一种是替换 window.XMLHttpRequest,另一种是重写 XMLHttpRequest.prototype 上的方法。我们采用的是第二种方法,重写了 XMLHttpRequest 原型的 opensend 方法。

open

重写之前,先保留原生的方法

// 保存原方法
const originalProto =  XMLHttpRequest.prototype
const originalOpen = originalProto.open

然后我们重写 open 方法中,并获取本次的请求方法

// 重写open方法
let cacheWay, cacheUrl
originalProto.open = function newOpen() {
  cacheWay = arguments[0].toUpperCase() // 请求方法
  return originalOpen.apply(this, arguments) // 调用原生方法
}

send

接下来重写 send 方法,还是先保存原生方法

const originalSend = XMLHttpRequest.prototype.send

在重写的 send 方法中,我们先获取请求时间

// 重写send方法
  originalProto.send = function newSend() {
    const startTime = Date.now() // 请求发出的时间
    // 立刻保存请方法,避免请求回来前发下一个请求,被覆盖
    const way = cacheWay
    this.addEventListener('loadend', onLoadend, true)
    return originalSend.apply(this, arguments)
    
    function onLoadend() {}
  }

然后为此次请求注册一个加载结束事件,在其中收集响应状态码响应时间响应信息

function onLoadend() {
  // 采集请求数据
  const endTime = Date.now() // 请求返回的时间
  const success = isSuccess(this.status) // 验证请求是否成功
  const reportData = {
    kind: 3, // 表示请求日志
    time: startTime, // 请求时间
    send_url: this.responseURL, // 请求路径
    way, // 请求方法
    success: success ? 0 : 1, // 请求是否成功
    status: this.status, // 状态码
    res_time: endTime - startTime, // 响应时间
    res_body: this.response ? this.response : this.statusText, // 响应信息
  }
  report(reportData) // 上报日志
  this.removeEventListener('loadend', onLoadend, true)
}

接下来我们展开解释一下上方的代码

请求的成败我们是依据状态码判断的,只要状态码小于 400,都是为请求成功

// 检测请求是否成功
function isSuccess(status) {
  return status < 400
}

响应时间就用请求返回的时间减去发出的时间计算就好了

res_time: endTime - startTime, // 响应时间

对于相应信息,一般是存在 response 属性中,但是当请求失败时,也就没有响应,从 statusText 获取失败的状态信息

res_body: this.response ? this.response : this.statusText, // 响应信息

report 是我们封装的一个发送请求的方法,为上报的日志对象添加了监控网站的id,其中网站id是通过脚本公开的方法提供给用户传入的,代码如下。

// 缓存 XMLHttpRequest 避免被mock修改
const _XMLHttpRequest = window.XMLHttpRequest

let web_id = 1018
// 公开选项设置
window.setOption = function (option = {}) {
  web_id = option.id || 1018
}

function report(data) {
  data.web_id = web_id
  // 请求发起的路径
  data.url = decodeURIComponent(location.hostname + location.pathname + location.hash)
  // 根据错误类型,请求发往不同的路径
  let url = 'http://47.100.57.184:3333/report/' 
  switch (data.kind) {
    case 0:
      url += 'err'
      break
    case 1:
      url += 'per'
      break
    case 2:
      url += 'beh'
      break
    case 3:
      url += 'http'
      break
  }
  // 避免无限请求,使用原生方法
  const xhr = new _XMLHttpRequest()
  originXML.open.call(xhr, 'POST', url, true)
  originXML.send.call(xhr, JSON.stringify(data))
}

由于 window.XMLHttpRequest 可能会被修改,所以我们在脚本运行之初就要缓存原生的 XMLHttpRequest 对象。

setOption 是我们想用户公开的方法,用于配置监控日志是属于哪个网站的

然后就是创建 XMLHttpRequest 发请求,注意为了避免上报请求也被采集,所以要采用原生方法发送。我们在重写发送方法时加一步,将其存入 originXML 对象中

const originalProto = XMLHttpRequest.prototype
const originalOpen = originalProto.open
const originalSend = originalProto.send
// 保存原生发放,供上报请求使用
originXML.open = originalOpen
originXML.send = originalSend

重写 fetch

fetch 也是一种很流行的请求方式,所以我们也要拦截,通过替换 window.fetch 来重写

整体思路和 XMLHttpRequest 一致,只是采集数据的时机不同

由于 fetch 返回的是一个 promise,所以我们使用 then 方法获取响应对象,获取各种响应数据

需要注意的是,响应对象的数据是流类型,只能读一次,所以操作前需要先 clone 一份

代码如下

function rewriteFetch() {
  const originalFetch = window.fetch // 保留原生方法
  window.fetch = function newFetch(url, config) {
    const startTime = Date.now() // 请求发起的时间
    return originalFetch(url, config).then(function (res) {
      // 采集请求数据
      res
        .clone() // 返回数据只能读取一次,需要先克隆
        .text() // 转换成文本
        .then(function (data) {
          const endTime = Date.now() // 请求返回的时间
          const success = isSuccess(res.status) // 验证请求是否成功
          const reportData = {
            kind: 3, // 表示请求日志
            time: startTime, // 请求时间
            send_url: res.url, // 请求路径
            way: (config ? config.method : 'GET').toUpperCase(), // 请求方法
            success: success ? 0 : 1, // 请求是否成功
            status: res.status, // 状态码
            res_time: endTime - startTime, // 响应时间
            res_body: data, // 响应信息
          }
          report(reportData) // 上报日志
        })
      return res
    })
  }
}

至此,网络请求的拦截就完成了,我们已经能够成功采集到请求的各项数据了。

异常报错监控

我们知道,在代码的运行时可能会发生各种异常,我们将其分为四类:脚本异常资源异常接口异常白屏异常

而我们要监控的数据有:异常的类型发生异常的时间发生异常的页面路径异常的报错信息异常的堆栈信息

脚本异常

JS 异常

脚本异常就是 js 代码运行过程中的异常,可以通过为 window 添加 error 事件监听,代码如下

window.addEventListener(
  'error',
  function (e) {
    let reportData = {
      kind: 0, // 异常日志
      type: 0, // 脚本异常
      time: Date.now(), // 异常的发生时间
      message: e.error.name + ': ' + e.error.message, // 报错信息
      stack: e.error.stack, // 堆栈信息
    }
    report(reportData) // 上报日志
  },
  true
)

Promise 异常

需要注意的是,在 Promise 中的报错是不会触发 error 事件的,而是要监听 unhandledrejection 事件,回调函数与上方代码不同就是信息的提取从错误对象error 变成了原因对象reason

window.addEventListener(
  'unhandledrejection',
  function (e) {
    const reportData = {
      kind: 0,
      type: 0,
      time: Date.now(),
      message: e.reason.name + ': ' + e.reason.message,
      stack: e.reason.stack,
    }
    report(reportData)
  },
  true
)

资源异常

资源异常顾名思义,就是网页中加载资源时出现的异常,比如 img、link、script 都会加载资源

资源加载异常

当加载资源失败时,也会触发 error 事件,与脚本异常不同的是事件对象的 target 身上会有 srchref 属性

我们区分这种异常,然后自己编写报错信息

window.addEventListener(
  'error',
  function (e) {
    let reportData
    if (e.target && (e.target.src || e.target.href)) {
      // 资源加载异常
      const fileName = getErrorFileName(e.target.src || e.target.href)
      reportData = {
        kind: 0, // 异常日志
        type: 1, // 资源异常
        time: Date.now(), // 异常的发生时间
        message: `Not Found: ${fileName}`, // 报错信息
        stack: `Not Found: ${e.target.src || e.target.href}`, // 异常堆栈
      }
      // console.dir(e.target)
    } else {
      // 脚本异常
      // ……
    }
    report(reportData) // 上报日志
  },
  true
)

其中用到了一个 getErrorFileName 的方法,其实就是切割字符串获取文件名

function getErrorFileName(src) {
  if (!src) return ''
  const arr = src.split('/')
  return arr[arr.length - 1]
}

CSS 加载异常

有个例外是 CSS 代码中加载的资源失败了,并不会触发 error 事件,需要额外的处理

div {
  background: url('./不存在的资源.png');
}

我们可以在页面完全加载后,通过 performance.getEntries 获取本页面加载的所有资源,过滤出属于 CSS 的部分,重发请求,根据请求的情况来判断该资源是否加载成功。

// 额外处理css中的资源异常,通过再发一次请求验证
window.addEventListener('load', function () {
  // 提取所有资源列表并过滤
  const entries = performance.getEntriesByType('resource')
  const srcEntries = entries.filter(function (val) {
    return val.initiatorType == 'css'
  })
  // 重发请求
  for (const item of srcEntries) {
    const xhr = new XMLHttpRequest()
    originXML.open.call(xhr, 'GET', item.name)
    originXML.send.call(xhr)
    xhr.addEventListener('loadend', onLoadend)
    
    function onLoadend() {
      if (isSuccess(xhr.status)) {
        // 请求成功,不处理
        return
      }
      // 请求失败 上报资源异常
      const reportData = {
        kind: 0, // 异常日志
        type: 1, // 接口异常
        time: Date.now(), // 异常的发生时间
        message: `Not Found: ${getErrorFileName(item.name)}`, // 报错信息
        stack: `Not Found: ${item.name}`, // 异常堆栈
      }
      report(reportData)
    }
  }
})

接口异常

其实在网络请求失败了,就表示发生了接口异常。这要在上一节网络请求监控中重写的 XMLHttpRequestfetch 方法中捕获

XMLHttpRequest

function rewriteXML() {
  // ……
  originalProto.send = function newSend(){
    // ……
    function onLoadend() {
      const endTime = Date.now() // 请求返回的时间
      const success = isSuccess(this.status) // 验证请求是否成功
      // ……
      // 请求失败,上报接口异常
      if (!success) {
        const reportData = {
          kind: 0, // 异常日志
          type: 2, // 接口异常
          time: endTime, // 异常的发生时间
          message: `${this.status} ${this.statusText}`, // 报错信息
          stack: `Failed when requesting ${this.responseURL}`, // 异常堆栈
        }
        report(reportData)
      }
    }
  }
}

fetch

function rewriteFetch() {
  const originalFetch = window.fetch // 保留原生方法
  window.fetch = function newFetch(url, config) {
    const startTime = Date.now() // 请求发起的时间
    return originalFetch(url, config).then(function (res) {
      // 采集请求数据
      res
        .clone() // 返回数据只能读取一次,需要先克隆
        .text() // 转换成文本
        .then(function (data) {
          const endTime = Date.now() // 请求返回的时间
          const success = isSuccess(res.status) // 验证请求是否成功
          // ……

          // 采集接口异常
          if (!success) {
            const reportData = {
              kind: 0, // 异常日志
              type: 2, // 接口异常
              time: endTime, // 异常的发生时间
              message: `${res.status} ${res.statusText}`, // 报错信息
              stack: `Failed when requesting ${res.url}`, // 异常堆栈
            }
            report(reportData) // 上报日志
          }
        })
      return res
    })
  }
}

白屏异常

白屏异常,就是因为某些原因,导致页面长时间白屏的异常。

引起白屏原因有很多,比如由于浏览器兼容问题导致的 JS 报错、服务器请求超时、CDN 报错等等

我们将这一时间定为 3 秒,也就是说持续三秒页面无任何元素的话,就视为产生了白屏异常

我们使用 document.elementsFromPoint 方法检测页面的中轴上是否有元素

// 白屏异常
setTimeout(function () {
  const width = window.innerWidth
  const height = window.innerHeight
  let emptyPoints = 18 // 空白点数
  // 页面中轴上的18个点,检测是否有元素渲染
  for (let i = 1; i < 10; i++) {
    isWrapper(document.elementsFromPoint(width / 2, (height / 10) * i)[0])
    isWrapper(document.elementsFromPoint((width / 10) * i, height / 2)[0])
  }
  // 页面中轴上没有元素,触发白屏异常
  if (emptyPoints == 18) {
    const reportData = {
      kind: 0, // 异常日志
      type: 3, // 白屏异常
      time: Date.now(), // 发生异常的时间
      message: 'White screen', // 异常信息
      stack: 'No DOM rendering for three seconds', // 异常堆栈
    }
    report(reportData)
  }
  // 检测坐标元素是否不为HTML和BODY
  function isWrapper(dom) {
    const tagName = dom.tagName
    if (tagName != 'HTML' && tagName != 'BODY') {
      emptyPoints--
    }
  }
}, 3000)

网页性能监控

网页打开时的性能,也是我们要监控的一个重要指标,包含数据:采集时间页面路径DNS解析耗时首屏渲染(FP)首次内容渲染(FCP)最大元素渲染(LCP)Dom Ready(DCL)页面完全加载(L)

采集的时间

首先要确定的就是采集的时间,由于我们要采集 L,就必须等待页面加载完成才可以采集,所以我们将采集时间定位触发 load 事件的一秒后

window.addEventListener('load', function () {
    setTimeout(function () {
      // 采集网页性能代码
    }, 1000)
  })

DNS | DCL | L

在采集的指标中,dns、dcl、l 可以直接通过 performance.timing 获取

const { 
  domainLookupStart, // dns 解析开始
  domainLookupEnd, // dns 解析结束
  fetchStart, // 页面请求发送
  domContentLoadedEventEnd, // dom完全加载
  loadEventEnd, // 页面完全加载
} = performance.timing

const dns = (domainLookupEnd - domainLookupStart) | 0 // dns解析
const dcl = (domContentLoadedEventEnd - fetchStart) | 0 // dom ready
const l = (loadEventEnd - fetchStart) | 0 // onload

FP | FCP

fp、fcp 则要通过 performance.getEntries 获取性能对象

当网站是个空页面或出现白屏异常时,可能获取不到这两个性能对象,用 dcl 代替

let fp = performance.getEntriesByName('first-paint')[0] // 首屏渲染时间
fp = fp ? fp.startTime | 0 : dcl
let fcp = performance.getEntriesByName('first-contentful-paint')[0] // 首次内容渲染时间
fcp = fcp ? fcp.startTime | 0 : fp

lcp

lcp 则要通过注册性能监视器(PerformanceObserver)并监听最大元素的渲染来获取

let lcp // 最大元素渲染时间
let observer = new PerformanceObserver(function (entryList) {
  // 每次最大元素被替换,更新时间
  let perfEntries = entryList.getEntries()
  lcp = perfEntries[0].startTime | 0
})
observer.observe({ entryTypes: ['largest-contentful-paint'] }) // 监听最大元素更新

然后就可以上报日志了,在提交时停止监听最大元素的渲染

const reportData = {
  kind: 1, // 性能日志
  time: Date.now(), // 日志产生的时间
  dns: dns | 0,
  fp: fp,
  fcp: fcp,
  lcp: lcp || fcp || dcl,
  dcl: dcl,
  l: l,
}
report(reportData) // 上报日志
observer.disconnect() // 停止监听最大元素的渲染

fmp | fid

最有意义元素渲染(FMP)首次交互延迟(FID) 也是网页关键的指标,获取方法与 lcp 类似,但由于并不是每次都触发,所以我们的监控脚本并不采集

仍展示一下采集的代码,这两个性能监视器只会触发一次,所以触发之后就可以断开连接了

let fmp // 最有意义的元素渲染
new PerformanceObserver((entryList, observer) => {
  let perfEntries = entryList.getEntries()
  fmp = perfEntries[0]
  observer.disconnect()
}).observe({ entryTypes: ['element'] })

let fid // 首次交互延迟
new PerformanceObserver((entryList, observer) => {
  let perfEntries = entryList.getEntries()
  let first = perfEntries[0]
  if (first) {
    fid = first.processingStart - first.startTime
  }
  observer.disconnect()
}).observe({ type: 'first-input', buffered: true })

用户行为监控

我们还需要监视用户的行为,采集的数据有:用户访问时间页面路径用户停留时间用户浏览器类型用户ip用户所处地区区分pv/pu

采集时机

由于我们要获取用户的停留时间,自然应该在用户离开时采集数据。

用户离开页面的方式有很多种,所以我们要监听5个事件

// 监听各种事件
window.addEventListener('hashchange', pageChangeHandler, {
  capture: true,
  passive: true,
})
window.addEventListener('pushState', pageChangeHandler, {
  capture: true,
  passive: true,
})
window.addEventListener('replaceState', pageChangeHandler, {
  capture: true,
  passive: true,
})
window.addEventListener('popstate', pageChangeHandler, {
  capture: true,
  passive: true,
})
window.addEventListener('beforeunload', pageChangeHandler, {
  capture: true,
  passive: true,
})

function pageChangeHandler() {
  // 采集上报性能日志……
}

在一些浏览器中,以上的事件并不能正确的激活,所以我们重写 history.pushStatehistory.replaceState 方法,确保用户的离开被正确监听

function rewriteHistory() {
  const prePushState = history.pushState
  history.pushState = function () {
    // 执行原生方法
    const res = prePushState.apply(this, arguments)
    pageChangeHandler() // 采集数据
    return res
  }
  const preReplaceState = history.replaceState
  history.replaceState = function () {
    // 执行原生方法
    const res = preReplaceState.apply(this, arguments)
    pageChangeHandler() // 采集数据
    return res
  }
}

其实以上的所有行为,其实并不应该都采集的,某些事件是否视为页面离开应该由系统使用者根据自己网站的性质来决定

多次上报

我们事无巨细的监听了所有离开的方式,但有些方式会同时触发多个事件,日志会多次上报,我们使用节流函数解决这一问题

let sign = true // 节流,避免重复上报
function pageChangeHandler() {
  if (!sign) return
  sign = false
  setTimeout(function () {
    sign = true
  }, 100)
  
  // 采集上报性能日志……
}

浏览器类型

根据 navigator.userAgent 判断浏览的类型,为了方便后端存储与统计,我们将其转成了数字

const browser = getBrowser()
function getBrowser() {
  // 获取浏览器 userAgent
  const ua = navigator.userAgent
  // 是否为 Opera
  const isOpera = ua.indexOf('Opera') > -1
  if (isOpera) {
    return 5 // Opera
  }

  // 是否为 IE
  const isIE = ua.indexOf('compatible') > -1 && ua.indexOf('MSIE') > -1 && !isOpera
  const isIE11 = ua.indexOf('Trident') > -1 && ua.indexOf('rv:11.0') > -1
  if (isIE11 || isIE) {
    return 4 // IE11
  }

  // 是否为 Edge
  const isEdge = ua.indexOf('Edg') > -1
  if (isEdge) {
    return 2 // Edge
  }

  // 是否为 Firefox
  const isFirefox = ua.indexOf('Firefox') > -1
  if (isFirefox) {
    return 3 // Firefox
  }

  // 是否为 Safari
  const isSafari = ua.indexOf('Safari') > -1 && ua.indexOf('Chrome') == -1
  if (isSafari) {
    return 6 // Safari
  }

  // 是否为 Chrome
  const isChrome = ua.indexOf('Chrome') > -1 && ua.indexOf('Safari') > -1 && ua.indexOf('Edge') == -1
  if (isChrome) {
    return 1 // Chrome
  }

  return 0 // 其他
}

ip 与地区

我们采用第三方工具获取用户的ip与地区,并使用 script 标签规避跨域问题。

与浏览器类型一样,我们这里也将地区转换成了数字,就不具体展示了

let ip = '0.0.0.0', area = 0
getIp()

// 借助sohu获取ip与地区
function getIp() {
  // 规避跨域问题
  const script = document.createElement('script')
  script.type = 'text/javascript'
  script.src = 'http://pv.sohu.com/cityjson'
  document.head.appendChild(script)
  
  script.onload = function () {
    // js执行完,获取ip与地区
    if (returnCitySN && returnCitySN.cip) {
      ip = returnCitySN.cip
    }
    if (returnCitySN && returnCitySN.cname) {
      area = areaList[returnCitySN.cname.slice(0, 2)] || 0
    }
  }
}

pv/uv

pv 表示网站的页面浏览量,用户每次访问页面,都会使 pv 加一;而 uv 表示网站的访问数,一名用户多次访问网站,uv 也只会加一

举个简单的例子:假设 1 名用户进入网站,又刷新了 9 次,pv 为 10,而 uv 为 1

我们上报时需要区分 pv/uv,为了减轻后端压力,这个问题由前端来解决。

我们可以在 localStorage 记录用户的上次访问时间,在用户离开时比较这两次访问时间,判断是否为同一天,以此来决定是否需要增加 uv

我们这里还区分了新老用户

const user = getUser() // 获取当前用户状态
// 0新用户;
// 1老用户今日首次登录;
// 2老用户今日再次登录
function getUser() {
  const key = '__user__'
  let time = localStorage.getItem(key)
  let res
  if (!time) {
    res = 0 // 新用户
  } else {
    const d1 = new Date(parseInt(time))
    const d2 = new Date()
    if (d1.getDay() == d2.getDay() && d1 - d2 < 24 * 3600 * 1000) {
      res = 2 // 老用户今日再次登录 不增加uv
    } else {
      res = 1 // 老用户今日首次登录
    }
  }
  // 更新数据
  localStorage.setItem(key, Date.now().toString())
  return res
}

路径与时间

我们要在页面访问时就记录下时间和路径,因为事件触发时页面已经跳转了,此时 loaction 的数据已经是新路径了

到这里,参数就收集完了,可以上报日志了,记得上报后要更新时间与路径

  // 访问时间
let startTime = Date.now()
// 记录当前路径
let preUrl = decodeURIComponent(location.hostname + location.pathname + location.hash)
// 其余变量…… 
function pageChangeHandler() {
  if (!sign) return
  sign = false
  setTimeout(function () {
    sign = true
  }, 100)
  
  const endTime = Date.now() // 离开时间
  const reportData = {
    kind: 2, // 行为日志
    time: endTime, // 上报事件
    url: preUrl, // 页面路径
    duration: endTime - startTime, // 停留时间
    browser, // 用户浏览器
    ip, // 用户ip
    area, // 用户地区
    user, // 区分pv/uv
  }
  report(reportData) // 上报日志
  // 更新时间与路径
  startTime = endTime
  preUrl = decode(location.hostname + location.pathname + location.hash)
}

preUrl = decodeURIComponent(location.hostname + location.pathname + location.hash)

结语

至此,网站所有的运行参数我们都已经成功监控了,完整代码请访问我们的仓库,数据展示请访问我们的系统

如果文章有不正确或存疑的地方,欢迎评论指出。

如果有帮到你,点赞关注一下吧。