aop android埋点 埋点sdk_aop android埋点

写在前面

博主最近半年的时间都在投入 concis react组件库的开发,最近阶段也是想要做一些市面组件库所没有的东西,concis 主要为业务平台开发提供了一系列组件,而埋点在业务中的实用性是很高的,搭配业务端埋点和后台监控,可以收集到很多信息,如性能参数、错误捕捉、请求响应过慢等一系列问题,因此本文记录了开发一个埋点SDK组件的全过程。

效果

先看使用方式吧,这是一个普通的React 项目中的 App.jsx 组件:

import React from 'react'
import { Route, Routes } from "react-router-dom";
import A from './pages/A';
import B from './pages/B'
import { Track } from 'concis'

function App() {
  const trackRef = React.useRef();

  // 用在项目根目录,定时上报,如每隔一小时上报一次
  setInterval(() => {
    getTrackData();
  }, 60 * 60 * 1000)

  function getTrackData() {
    const res = trackRef.current.callbackTrackData();
    //接口上报...
  }

  return (
    <div>
      <Routes>
        <Route path="/" element={<A />} />
        <Route path="/a" element={<A />} />
        <Route path="/b" element={<B />} />
      </Routes>
      <Track ref={trackRef} />
    </div>
  )
}

Track 组件运行在项目根目录,做到每个页面信息收集的作用,并且向外暴露了 callbackTrackData api,可以结合业务中的场景,在指定时刻收集信息,并上报到后端。

思路

Track 组件本身并不复杂,其实就是将一系列数据采集、信息捕捉、请求拦截汇集在了组件内部,并记录在状态中,在需要的时候向外暴露。

因此在组件中定义这些状态:

const Track = (props, ref) => {
  const { children } = props;

  const [performanceData, setPerformanceData] = useState({});
  const xhrRequestResList = useRef([]);
  const fetchRequestResList = useRef([]);
  const resourceList = useRef({});
  const userInfo = useRef({});
  const errorList = useRef([]);
  const clickEventList = useRef([]);
  
  //...
  
  return (
      //...
  )
  
}
  • performanceData用于收集页面性能相关参数,如FP、FCP、FMP、LCP、DOM Load、white time等一系列参数。
  • xhrRequestResList用于捕获页面中所有xhr 请求,收集请求方式、响应完成时间。
  • fetchRequestResList用于捕获页面中所有fetch 请求,收集请求方式、响应完成时间。
  • resourceList用于收集页面中所有文件、静态资源的请求数据,如jscssimg
  • userInfo用于收集用户相关信息,如浏览器参数、用户IP、城市、语言等。
  • errorList用于收集发生在生产环境下错误的捕获,包括errorrejectError
  • clickEventList用于收集用户在页面上的点击行为。

performanceData

页面加载相关的性能参数代码如下:

const collectPerformance = async () => {
    const fp = await collectFP();
    const fcp = await collectFCP();
    const lcp = await collectLCP();
    const loadTime = await collectLoadTime();
    const dnsQueryTime = collectDNSQueryTime();
    const tcpConnectTime = collectTCPConnectTime();
    const requestTime = collectRequestTime();
    const parseDOMTreeTime = collectParseDOMTree();
    const whiteTime = collectWhiteTime();
    setPerformanceData({
      fp,
      fcp,
      lcp,
      dnsQueryTime,
      tcpConnectTime,
      requestTime,
      parseDOMTreeTime,
      whiteTime,
      loadTime,
    });
  };

这里以fpfcp举例,主要用到了PerformanceObserver api,收集这些参数,代码如下:

const collectFP = () => {
  return new Promise((resolve) => {
    const entryHandler = (list) => {
      for (const entry of list.getEntries()) {
        if (entry.name === 'first-paint') {
          resolve(entry);
          observer.disconnect();
        }
      }
    };
    const observer = new PerformanceObserver(entryHandler);
    observer.observe({ type: 'paint', buffered: true });
  });
};

const collectFCP = () => {
  return new Promise((resolve) => {
    const entryHandler = (list) => {
      for (const entry of list.getEntries()) {
        if (entry.name === 'first-contentful-paint') {
          resolve(entry);
          observer.disconnect();
        }
      }
    };
    const observer = new PerformanceObserver(entryHandler);
    observer.observe({ type: 'paint', buffered: true });
  });
};

aop android埋点 埋点sdk_javascript_02

而其他参数则是直接使用了 window.performance.timing 计算得来。

xhrRequestResList

捕获xhr请求其实很简单,在原有的XMLHttpRequest.prototype上的opensend方法上记录我们所需要的参数,如urlmethod,同时在send方法中介入loadend方法,当请求完成,整理参数。

// 统计每个xhr网络请求的信息
const monitorXHRRequest = (callback) => {
  const originOpen = XMLHttpRequest.prototype.open;
  const originSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.open = function newOpen(...args) {
    this.url = args[1];
    this.method = args[0];
    originOpen.apply(this, args);
  };

  XMLHttpRequest.prototype.send = function newSend(...args) {
    this.startTime = Date.now();

    const onLoadend = () => {
      this.endTime = Date.now();
      this.duration = this.endTime - this.startTime;

      const { status, duration, startTime, endTime, url, method } = this;
      const reportData: xhrRequestType = {
        status,
        duration,
        startTime,
        endTime,
        url,
        method: (method || 'GET').toUpperCase(),
        success: status >= 200 && status < 300,
        subType: 'xhr',
        type: 'performance',
      };
      callback(reportData);
      this.removeEventListener('loadend', onLoadend, true);
    };

    this.addEventListener('loadend', onLoadend, true);
    originSend.apply(this, args);
  };
};

aop android埋点 埋点sdk_react.js_03

当状态码在200~300之间,则判定为success,最后通过异步回调函数的机制,回传到组件中,加入状态。

fetchRequestResList

捕获 fetch 思路和 xhr 类似,只不过fetch 本身基于 promise 实现,在重写 fetch api的时候通过promise的形式去写就可以。

// 统计每个fetch请求的信息
const monitorFetchRequest = (callback) => {
  const originalFetch = window.fetch;

  function overwriteFetch() {
    window.fetch = function newFetch(url, config) {
      const startTime = Date.now();
      const reportData: fetchRequestType = {
        startTime,
        endTime: 0,
        duration: 0,
        success: false,
        status: 0,
        url,
        method: (config?.method || 'GET').toUpperCase(),
        subType: 'fetch',
        type: 'performance',
      };
      return originalFetch(url, config)
        .then((res) => {
          reportData.endTime = Date.now();
          reportData.duration = reportData.endTime - reportData.startTime;
          const data = res.clone();
          reportData.status = data.status;
          reportData.success = data.ok;
          callback(reportData);
          return res;
        })
        .catch((err) => {
          reportData.endTime = Date.now();
          reportData.duration = reportData.endTime - reportData.startTime;
          reportData.status = 0;
          reportData.success = false;
          callback(reportData);
          throw err;
        });
    };
  }
  overwriteFetch();
};

xhr一样,最后通过异步回调函数的形式回传到组件中。

resourceList

获取页面中网络请求以外的其他资源,通过 window.performance.getEntriesByType api,整理出指定资源的信息,最后组装成一个resource 列表。

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: resourceItemType<number> & PerformanceEntry) => {
    const arry = resource[item.initiatorType];
    arry &&
      arry.push({
        name: item.name, // 资源名称
        type: 'resource',
        sourceType: item.initiatorType, // 资源类型
        duration: +item.duration.toFixed(2), // 资源加载耗时
        dns: item.domainLookupEnd - item.domainLookupStart, // DNS 耗时
        tcp: item.connectEnd - item.connectStart, // 建立 tcp 连接耗时
        redirect: item.redirectEnd - item.redirectStart, // 重定向耗时
        ttfb: +item.responseStart.toFixed(2), // 首字节时间
        protocol: item.nextHopProtocol, // 请求协议
        responseBodySize: item.encodedBodySize, // 响应内容大小
        resourceSize: item.decodedBodySize, // 资源解压后的大小
        isCache: isCache(item), // 是否命中缓存
        startTime: performance.now(),
      });
  });
  function isCache(entry) {
    // 直接从缓存读取或 304
    return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0);
  }

  return resource;
};

aop android埋点 埋点sdk_前端_04

userInfo

用户信息分为两类:

  • 浏览器(navigator)的信息;
  • 用户本身的信息,如IP、所在位置;

这里获取第二类的方式通过第三方接口接入:

const getUserIp = () => {
  return new Promise((resolve, reject) => {
    const scriptElement = document.createElement('script');
    scriptElement.src = `https://pv.sohu.com/cityjson?ie=utf-8`;
    document.body.appendChild(scriptElement);
    scriptElement.onload = () => {
      try {
        document.body.removeChild(scriptElement);
        // @ts-ignore
        resolve(window.returnCitySN);
      } catch (e) {
        reject(e);
      }
    };
  });
};

获取浏览器相关的参数代码如下:

const getNativeBrowserInfo = () => {
  const res: nativeBrowserInfoType = {};
  if (document) {
    res.domain = document.domain || ''; // 获取域名
    // res.url = String(document.URL) || ''; //当前Url地址
    res.title = document.title || '';
    // res.referrer = String(document.referrer) || ''; //上一跳路径
  }
  // Window对象数据
  if (window && window.screen) {
    res.screenHeight = window.screen.height || 0; // 获取显示屏信息
    res.screenWidth = window.screen.width || 0;
    res.color = window.screen.colorDepth || 0;
  }
  // navigator对象数据
  if (navigator) {
    res.lang = navigator.language || ''; // 获取所用语言种类
    res.ua = navigator.userAgent.toLowerCase(); // 运行环境
  }
  return res;
};

总体列表如图:

aop android埋点 埋点sdk_前端_05

errorList

捕捉错误分为了同步错误(console.aaa(123))和异步错误(promise所遗漏未捕捉到的reject)

因此在全局挂载两个通用事件,当捕获到错误时推入错误列表中即可。

const getJavaScriptError = (callback) => {
  window.addEventListener('error', ({ message, filename, type }) => {
    callback({
      msg: message,
      url: filename,
      type,
      time: new Date().getTime(),
    });
  });
};

const getJavaScriptAsyncError = (callback) => {
  window.addEventListener('unhandledrejection', (e) => {
    callback({
      type: 'promise',
      msg: (e.reason && e.reason.msg) || e.reason || '',
      time: new Date().getTime(),
    });
  });
};

aop android埋点 埋点sdk_aop android埋点_06

clickEventList

收集点击行为信息原理是全局挂载mouseDowntouchstart事件,在触发事件时收集DOM事务相关信息,代码如下:

const onClick = (callback) => {
  ['mousedown', 'touchstart'].forEach((eventType) => {
    let timer;
    window.addEventListener(eventType, (event) => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        const target = event.target as eventDom & EventTarget;
        const { top, left } = (target as any).getBoundingClientRect();
        callback({
          top,
          left,
          eventType,
          pageHeight: document.documentElement.scrollHeight || document.body.scrollHeight,
          scrollTop: document.documentElement.scrollTop || document.body.scrollTop,
          type: 'behavior',
          subType: 'click',
          target: target.tagName,
          paths: (event as any).path?.map((item) => item.tagName).filter(Boolean),
          startTime: event.timeStamp,
          outerHTML: target.outerHTML,
          innerHTML: target.innerHTML,
          width: target.offsetWidth,
          height: target.offsetHeight,
          viewport: {
            width: window.innerWidth,
            height: window.innerHeight,
          },
        });
      }, 500);
    });
  });
};

可以捕获到对应DOM节点的触发时机和位置信息,在后台分析数据时过滤出指定的DOM可以对于热门用户行为进行分析报告。

aop android埋点 埋点sdk_前端_07

写在最后

至此,Track 组件写完了,业务方可以结合Track 组件在需要上报的时机进行数据收集,这其实是 concis 给业务端做出的收集层的便捷,由于上报的业务场景太多,本来是想在组件内部一起做完的,最后还是决定组件层只做数据收集,分析和上报留给业务方。