你只會用前端數(shù)據(jù)埋點 SDK 嗎?

前言
相信不少人因為項目中沒有接觸過數(shù)據(jù)埋點相關(guān)的內(nèi)容,而沒有花時間去了解它,總覺得這又是一個自己還不能涉及的方面,然而數(shù)據(jù)埋點本身并不難理解,只是很難做得好,本文會從 認識數(shù)據(jù)埋點 SDK、設(shè)計前端數(shù)據(jù)埋點 SDK 兩個核心方面來展開,聊聊前端數(shù)據(jù)埋點的那些事。

認識數(shù)據(jù)埋點 SDK
SDK 全稱是 Software Development Kit 即 軟件開發(fā)工具包,一般都是一些軟件工程師為特定的軟件包、軟件框架、硬件平臺、操作系統(tǒng)等建立應(yīng)用軟件時的開發(fā)工具的集合。

為什么需要前端數(shù)據(jù)埋點?
對產(chǎn)品本身而言,我們需要關(guān)注內(nèi)容包括如下幾個方面:

用戶在產(chǎn)品里 主要做什么操作、停留多久、訪問幾次
用戶點擊率占比如何,會不會出現(xiàn)某些功能設(shè)計對于用戶而言是無效的
用戶在核心使用流程上是否順暢,頁面反饋是否正常友好
可能有哪些潛在的用戶的功能需要更新
總的來說,數(shù)據(jù)埋點 核心是為了 收集數(shù)據(jù)(有了數(shù)據(jù)就可以為所欲為),只有通過分析數(shù)據(jù),才能更好的評估出整個項目的質(zhì)量和重要性(數(shù)據(jù)為王),并且能夠為產(chǎn)品優(yōu)化指明方向(數(shù)據(jù)驅(qū)動產(chǎn)品)。

前端數(shù)據(jù)埋點要考慮哪些方面?
數(shù)據(jù)埋點的核心是數(shù)據(jù)收集,而與數(shù)據(jù)相關(guān)的內(nèi)容不外乎如下幾個內(nèi)容:

數(shù)據(jù)又是基于應(yīng)用產(chǎn)生的,因為沒有應(yīng)用就不會有相關(guān)的數(shù)據(jù)
應(yīng)用本身要提供展示、收集、操作內(nèi)容,而這是基于平臺的,比如網(wǎng)站就是基于瀏覽器平臺
有應(yīng)用、有平臺就得有用戶,因為應(yīng)用本身就是為了給用戶提供好用的功能去解決某些存在的問題
針對開發(fā)者而言,應(yīng)用就是代碼,代碼運行的質(zhì)量也能決定應(yīng)用的質(zhì)量,而顯式質(zhì)量體現(xiàn)在錯誤或警告上
總結(jié)下來,數(shù)據(jù)埋點其實要考慮的就是 用戶行為、錯誤警告、頁面性能 三個核心方面。

用戶行為
用戶行為就是在網(wǎng)頁應(yīng)用中進行的一系列操作,但用戶的操作有很多種,都需要記錄下來是不可能的,一般需要記錄用戶的以下幾種行為:

用戶瀏覽頁面次數(shù),PV(Page View)
用戶每次訪問網(wǎng)站中的一個頁面就被記錄為 1 個 PV,多次訪問同一個頁面,訪問量就會累計
頁面瀏覽用戶數(shù),UV(Unique visitor)
通過網(wǎng)絡(luò)正常訪問頁面的使用者,通常一臺電腦客戶端或一個用戶賬號為一個訪客,一般同一個客戶端或用戶賬號在 24h 內(nèi)多次訪問只會被記錄為 1 個 UV,計算策略視具體情況而定
用戶點擊按鈕次數(shù)
以上兩種可以認為是 **自動式觸發(fā)埋點**,而點擊按鈕次數(shù)就屬于是 **互動式觸發(fā)埋點**,便于去了解這個功能按鈕的使用情況
錯誤警告
頁面中代碼運行產(chǎn)生的錯誤,可能會導致用戶核心操作流程被中斷,為了避免大量用戶受到影響,我們需要獲取 生產(chǎn)環(huán)境的錯誤數(shù)據(jù),這樣才能便于開發(fā)者及時進行修復。

通常來講代碼中的錯誤會包含以下幾大類:

全局錯誤,即未被捕獲的錯誤
局部錯誤,即通過 try...catch、promise.then、promise.catch 等捕獲的錯誤
接口請求錯誤,即在二次封裝請求 API 中進行請求和接收響應(yīng)時的錯誤
組件級錯誤,即使用 Vue/React 組件時發(fā)生的錯誤
頁面性能
頁面性能其實也是前端性能優(yōu)化中一個需要考慮和優(yōu)化的點,畢竟如果一個網(wǎng)站老是發(fā)生 白屏、交互卡頓、頁面資源加載時間長 等問題,肯定是沒辦法留住用戶的,特別是用戶的真實環(huán)境各不相同,如 Windows x、MACOS、Android、iOS 等,更加需要統(tǒng)計和收集相關(guān)數(shù)據(jù),便于進行集中優(yōu)化處理,提升用戶體驗。

與頁面性能指標相關(guān)的內(nèi)容,在之前的 前端性能優(yōu)化到底該怎么做(上)— 開門見山 一文中有提到,這里大致總結(jié)下:

首次繪制(First Paint,F(xiàn)P)
在渲染進程確認要渲染當前響應(yīng)資源后,渲染進程會先創(chuàng)建一個空白頁面,通常把創(chuàng)建空白頁面的這個時間點稱為 First Paint,簡稱 FP
所謂的 白屏時間 其實指的就是創(chuàng)建這個空白頁面到瀏覽器開始渲染非空白內(nèi)容的時間,比如頁面背景發(fā)生變化等
首次內(nèi)容繪制(First Contentful Paint,F(xiàn)CP)
當用戶看見一些 "內(nèi)容" 元素被繪制在頁面上的時間點,和白屏是不一樣,它可以是 文本 首次繪制,或 SVG 首次出現(xiàn),或 Canvas 首次繪制等,即當頁面中繪制了第一個 像素 時,這個時間點稱為 First Content Paint,簡稱 FCP
首屏時間 / 最大內(nèi)容繪制(Largest Contentful Paint, LCP)
LCP 是一種新的性能度量標準,LCP 側(cè)重于用戶體驗的性能度量標準,與現(xiàn)有度量標準相比,更容易理解與推理,當首屏內(nèi)容完全繪制完成時,這個時間點稱為 Largest Content Paint,簡稱 LCP
最大內(nèi)容繪制應(yīng)在 2.5s 內(nèi)完成
首次輸入延遲(First Input Delay, FID)
FID 測量的是當用戶第一次在頁面上交互的時候(點擊鏈接、點擊按鈕 或 自定義基于 js 的事件),到瀏覽器實際開始處理這個事件的時間
首次輸入延遲應(yīng)在 100ms 內(nèi)完成
累積布局偏移(Cumulative Layout Shift, CLS)
CLS 是為了測量 視覺穩(wěn)定性,以便提供良好的用戶體驗
累積布局偏移應(yīng)保持在 0.1 或更少
首字節(jié)達到時間(Time to First Byte,TTFB)
指的是瀏覽器開始收到服務(wù)器響應(yīng)數(shù)據(jù)的時間(后臺處理時間 + 重定向時間),是反映服務(wù)端響應(yīng)速度的重要指標
TTFB 時間如果超過 500ms,用戶在打開網(wǎng)頁的時就會感覺到明顯的等待
理解了 為什么要做前端數(shù)據(jù)埋點 和 前端數(shù)據(jù)埋點所需要統(tǒng)計數(shù)據(jù)的方方面面,接下來我們就需要設(shè)計一個自己的 前端數(shù)據(jù)埋點 SDK 了。

設(shè)計前端數(shù)據(jù)埋點 SDK
這里只我們考慮數(shù)據(jù)埋點的核心內(nèi)容,因此不會涉及得肯定沒有那么全面,而一開始也不可能設(shè)計得全面,只要保證核心功能,那么在基于核心進行擴展即可。

確定 options 和 data 內(nèi)容
應(yīng)用的唯一標識 — options.AppId
數(shù)據(jù)埋點 SDK 作為一個通用的工具集,是可供多個系統(tǒng)進行使用的,而這就意味著需要去保證每個應(yīng)用的唯一性,一般來講,在初始化 SDK 的時候是需要接入方提供的當前應(yīng)用的 ID。

那這個 ID 從何而來?隨便生成嗎?一般來說需要經(jīng)過如下步驟:

在對應(yīng)監(jiān)控系統(tǒng)上為當前應(yīng)用生成唯一的 AppId
在對應(yīng)應(yīng)用接入 SDK 時作為配置項之一傳入
其實還會涉及到請求 url 內(nèi)容,主要用于發(fā)送給對應(yīng)的監(jiān)控系統(tǒng),因此 options 核心內(nèi)容簡單設(shè)計如下:

{
  appId: '', // 當前應(yīng)用唯一標識
  baseUrl: '', // 數(shù)據(jù)發(fā)送的地址
}

數(shù)據(jù)發(fā)送格式 — data
由于需要收集的數(shù)據(jù)類型包含多種,最好能夠定義一種比較通用的數(shù)據(jù)格式,便于更友好地進行數(shù)據(jù)收集。

這里簡單定義一下數(shù)據(jù)格式,大致如下,格式隨需求場景產(chǎn)生差異:

{
  appId: '', // 當前應(yīng)用唯一標識
  type: 'action' | 'performance'| 'network' | 'error', // 不同數(shù)據(jù)類型
  pageUrl: '', // 頁面地址
  apiUrl: '', // 接口地址
  userId: '', // 當前用戶 id
  userName: '', // 當前用戶 name
  time: '',// 觸發(fā)記錄的時間
  data: {}, // 接口響應(yīng)結(jié)果 | 性能指標 | 錯誤對象 | 用戶操作相關(guān)信息
}

確定數(shù)據(jù)發(fā)送方式
如果要問前端埋點最基本要實現(xiàn)的功能是什么,那必然是 數(shù)據(jù)發(fā)送 的能力,否則即便有應(yīng)用、有用戶、有數(shù)據(jù)也只能保存在本地沒法發(fā)送給相應(yīng)的監(jiān)控系統(tǒng),意味就沒法進行收集和統(tǒng)計(數(shù)據(jù)等于白給)。

那么數(shù)據(jù)發(fā)送都有什么方式呢?針對這個問題把 數(shù)據(jù)發(fā)送 翻譯成 請求發(fā)送 就容易多了,轉(zhuǎn)而問題就變成了 請求發(fā)送方式都有哪些?

一般會包括如下幾種(包括但不限于):

XMLHttpRequest
fetch
form 表單的 action
基于元素 src 屬性的請求
img 標簽的 src
script 標簽的 src
Navigator.sendBeacon()
這里選擇的是最后一種,因為 Navigator.sendBeacon() 就是專門用于通過 HTTP POST 將統(tǒng)計數(shù)據(jù) 異步 發(fā)送到 Web 服務(wù)器上,同時能避免傳統(tǒng)技術(shù)發(fā)送分析數(shù)據(jù)的一些問題。

傳統(tǒng)技術(shù)發(fā)送統(tǒng)計數(shù)據(jù)的一些問題,可以直接通過 *傳送門* 查看,由于文章篇幅有限不在額外解釋。

SDK 核心代碼
這里我們只考慮極簡情況,設(shè)計好的 SDK 代碼內(nèi)容比較簡單,直接上代碼:

let SDK = null // EasyAgentSDK 實例對象
const QUEUE = [] // 任務(wù)隊列
cosnt NOOP = (v) => v

// 通過 web-vitals 頁面性能指標
const reportWebVitals = (onPerfEntry) => {
  if (onPerfEntry && onPerfEntry instanceof Function) {
    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
      getCLS(onPerfEntry) // 布局偏移量
      getFID(onPerfEntry) // 首次輸入延遲時間
      getFCP(onPerfEntry) // 首次內(nèi)容渲染時間
      getLCP(onPerfEntry) // 首次最大內(nèi)容渲染時間
      getTTFB(onPerfEntry) // 首個字節(jié)到達時間
    })
  }
}

export default class EasyAgentSDK {
  appId = ''
  baseUrl = ''
  timeOnPage = 0
  config = {}
  onPageShow = null
  onPagesHide = null
 
  constructor(options = {}) {
    if (SDK) return

    SDK = this
    this.appId = options.appId
    this.baseUrl = options.baseUrl || window.location.origin
    this.onPageShow = options.onPageShow || NOOP
    this.onPagesHide = options.onPagesHide || NOOP

    // 初始化監(jiān)聽頁面變化
    this.listenPage()
  }
 
  // 設(shè)置 config
  setConfig(congfig){
    this.config = congfig
  }

  // 刷新任務(wù)隊列
  flushQueue() {
    Promise.resolve().then(() => {
      QUEUE.forEach((fn) => fn())
      QUEUE.length = 0;
    })
  }

  // 監(jiān)聽頁面變化
  listenPage() {
    let pageShowTime = 0

    window.addEventListener('pageshow', () => {
      pageShowTime = performance.now()
      
       // 頁面性能指標上報
      reportWebVitals((data) => {
        this.performanceReport({ data })
      })
      
      // 執(zhí)行 onPageShow
      this.onPageShow();
    })

    window.addEventListener('pagehide', () => {
      // 記錄用戶在頁面停留時間
      this.timeOnPage = performance.now() - pageShowTime
      
      // 刷新隊列前執(zhí)行 onPagesHide
      this.onPagesHide();

      // 刷新任務(wù)隊列
      this.flushQueue()
    })
  }

  // Json 轉(zhuǎn) FormData
  json2FormData(data){
    const formData = new FormData()

    Object.keys(data).forEach(key => {
      formData.append(key, data[key])
    });

    return formData
  }

  // 自定義上報類型
  report(config) {
    QUEUE.push(() => {
      const formData = json2FormData({
        ...this.config,
        ...config,
        time: new Date().toLocaleString(),
        appId: this.appId,
        pageUrl: window.location.href,
      });
      navigator.sendBeacon(`${this.baseUrl}${config.url || ''}`, formData)
    })
  }

  // 用戶行為上報
  actionReport(config) {
    this.report({
      ...config,
      type: 'action',
    })
  }

  // 網(wǎng)絡(luò)狀況上報
  networkReport(config) {
    this.report({
      ...config,
      type: 'network',
    })
  }

  // 頁面性能指標上報
  performanceReport(config) {
    this.report({
      ...config,
      type: 'performance',
    })
  }

  // 錯誤警告上報
  errorReport(config) {
    this.report({
      ...config,
      type: 'error',
    })
  }
}

上報用戶行為
統(tǒng)計 PV 和 UV — 自動觸發(fā)埋點
關(guān)于 PV 和 UV 在上述已經(jīng)做過介紹了,本質(zhì)上這兩個數(shù)據(jù)統(tǒng)計都可在一個上報類型為 action 數(shù)據(jù)發(fā)送中獲得,主要看監(jiān)控系統(tǒng)是按照怎樣的規(guī)則對數(shù)據(jù)進行分析和統(tǒng)計,這里在 SDK 內(nèi)部監(jiān)聽了頁面的 pageshow / pagehide 兩個事件:



pageshow
中可以上報與

PV / UV

相關(guān)的數(shù)據(jù) 和 頁面性能相關(guān)的數(shù)據(jù)

window.SDK = new EasyAgentSDK({
    appId: 'application_id',
    baseUrl: '//aegis.example.com/collect',
    onPageShow() {
        window.SDK.actionReport({
            data: {} // 其他必要傳遞的信息
        })
    }
});

window.SDK.setConfig({
    userId: UserInfo.userId, // 當前用戶 id
    userName: UserInfo.userName, // 當前用戶 name
});

在 pagehide 中主要用于計算用戶停留在頁面上的時間 timeOnPage 和 刷新任務(wù)隊列

統(tǒng)計用戶點擊按鈕 — 交互式觸發(fā)埋點
假設(shè)我們希望記錄某些按鈕的使用次數(shù)的數(shù)據(jù),可以在 document 上監(jiān)聽 click 事件,目的利用事件冒泡以便于不需要侵入不同按鈕的 click 事件,比如:

const TargetElementFilter = ['export_btn']

const findTarget = (filters) => {
 return filters.find((filter) => TargetElementFilter.find((v) => filter === v)));
}

document.addEventListener('click', (e) => {
  const { id, className, outerHTML } = e.target
  const isTarget = findTarget([id, className])

  if (isTarget) {
    SDK.actionReport({
      data: {
        id,
        className,
        outerHTML
      }, // 其他必要傳遞的信息
    })
  }
})

上報頁面性能
和頁面性能相關(guān)的內(nèi)容,屬于 SDK 自動觸發(fā)埋點,不應(yīng)該讓使用者在手動接入,在上面的實現(xiàn)中,我們在 pageshow 事件中通 reportWebVitals 和 performanceReport 進行數(shù)據(jù)上報,并且這里選擇了 Google 推出的 web-vitals 來獲取和頁面性能指標相關(guān)的具體數(shù)據(jù),對應(yīng)代碼為:

// 通過 web-vitals 頁面性能指標
const reportWebVitals = (onPerfEntry) => {
  if (onPerfEntry && onPerfEntry instanceof Function) {
    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
      getCLS(onPerfEntry) // 布局偏移量
      getFID(onPerfEntry) // 首次輸入延遲時間
      getFCP(onPerfEntry) // 首次內(nèi)容渲染時間
      getLCP(onPerfEntry) // 首次最大內(nèi)容渲染時間
      getTTFB(onPerfEntry) // 首個字節(jié)到達時間
    })
  }
}

獲取得到的數(shù)據(jù)大致如下:

上報錯誤警告
全局錯誤
全局錯誤,即未被捕獲的錯誤,可以通過 window.onerror 事件來捕獲,然后進行錯誤數(shù)據(jù)上報,大致如下:

window.addEventListener('error', (reason) => {
    const { filename, message, error } = reason;

    window.SDK.errorReport({
        data: {
            filename,
            message,
            error
        }
    });
})

局部錯誤
局部錯誤,即通過 try...catch、promise.then、promise.catch 等捕獲的錯誤,大致使用如下:

 try {
    throw new Error('error for test')
  } catch(error) {
    window.SDK.errorReport({
      data: {
        error,
      },
    })
  }


  Promise.reject(new Error('Promise reject for test'))
  .then(
    () => {},
    (reason) => {
      window.SDK.errorReport({
        data: {
            error: reason
        }
    });
    },
  )
 
  Promise.reject(new Error('Promise reject for test'))
  .catch(
    (reason) => {
      window.SDK.errorReport({
        data: {
            error: reason
        }
    });
    },
  )

接口請求錯誤
接口請求錯誤,即在二次封裝請求 API 中進行請求和接收響應(yīng)時的錯誤,為了方便這里以 axios 來舉例子,我們可以在它的 請求攔截 和 響應(yīng)攔截 的第二個回調(diào)參數(shù)中去上報對應(yīng)的錯誤數(shù)據(jù)信息,大致如下:

// 創(chuàng)建axios實例
const service = axios.create({
  baseURL, // api 的 base_url
  timeout: 60000, // 請求超時時間
  responseType: reqConf.responseType,
});

// 請求攔截
service.interceptors.request.use(
  (config) => {
    ...
    return config;
  },
  (error) => {
    window.SDK.errorReport({
      apiUrl: config.url,
      data: {
        error,
      },
    })
  },
);

// 響應(yīng)攔截
service.interceptors.response.use(
  (config: any) => {
    ...
    return config;
  },
  (error: any) => {
    window.SDK.errorReport({
      apiUrl: config.url,
      data: {
        error,
      },
    })

    return error.response.data;
  },
);

組件級錯誤
組件級錯誤,即使用 Vue / React 框架組件時發(fā)生的錯誤,完全可以使用它們在官方文檔中提到的錯誤捕獲方式來捕獲并上報錯誤。

Vue

中的

errorHandler

就是用于為應(yīng)用內(nèi)拋出的未捕獲錯誤指定一個全局處理函:

// App.vue
onMounted(()=>{
  throw new Error('error in onMounted')
});

// main.ts
const app = createApp(App)

app.config.errorHandler = (error, instance, info) => {
    window.SDK.errorReport({
        data: {
            instance,
            info,
            error
        }
    });
}

React

中的

ErrorBoundary

錯誤邊界相關(guān)的

getDerivedStateFromError


componentDidCatch
鉤子

// 定義錯誤邊界組件
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {    
      // 更新 state 使下一次渲染能夠顯示降級后的 UI    
      return { hasError: true };  
  }
  componentDidCatch(error, info) {    
      // 可以將錯誤日志上報給服務(wù)器    
      window.SDK.errorReport({
        data: {
            info,
            error
        }
    });
  }
  render() {
    if (this.state.hasError) {      
        // 自定義降級后的 UI 并渲染      、
        return <h1>Something went wrong.</h1>;    
    }
    return this.props.children;
  }
}

// 使用錯誤邊界組件
<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

最后
現(xiàn)在我們了解了 前端數(shù)據(jù)埋點 SDK 的二三事,通過上面的例子可能讓你覺得看起來比較簡單,但是真的要做好數(shù)據(jù)埋點也必然沒有那么容易,比如好需要考慮你的 SDK 數(shù)據(jù)發(fā)送的時間、發(fā)送的次數(shù)、需不需要將某些數(shù)據(jù)信息整合在一起只發(fā)送一次、怎么避免網(wǎng)絡(luò)擁塞等等問題。

作者:熊的貓

原文:https://juejin.cn/post/7163046672874864676

作者:熊的貓


歡迎關(guān)注微信公眾號 :深圳灣碼農(nóng)