手把手教你搭建一個無框架埋點體系

背景

埋點體系構成

一般來說,一個完整的埋點體系由以下三個部分構成:

埋點上報是將應用層事件上傳至上層平臺的過程。比方說,在某購物網站上,用戶點擊了「收藏」按鈕,此時,一個點擊事件就生成了,這一事件會被上報至一個數據分析平臺。這樣,相關的數據分析師、產品經理、運營等同學便可以在數據分析平臺,通過這些上報的事件數據分析,得出應用中可以優化的方方面面。由此可見,埋點上報是每個產品走向卓越的重要一環。

通過以上描述,我們認識了埋點上報過程的兩大主角:應用與數據分析平臺。從前端技術的角度來說,我們通常還需要第三個角色的助攻,那就是數據平臺 SDK. 這個 SDK 封裝了數據分析平臺的各種接口,暴露出簡單的方法讓我們進行調用,實現簡易的埋點上傳。

兩種埋點事件

我們可以把應用層事件分爲兩大類:

我們爲這兩種事件分別開發了一套埋點上傳 SDK。下面,我們就來詳細地講解一下這兩套 SDK 的技術知識。

處理「頁面事件」的 SDK - monitor-tracer

monitor-tracer 是一個用來監控頁面及組件可見時長和活躍時長的前端 SDK,同時也是 Monitor 前端埋點體系的一個核心組成部分。

背景

爲了更好地理解用戶對各個業務功能的使用狀況,從而進行相應的產品優化和調整:

基於以上需求,我們開發了 monitor-tracer SDK, 旨在實現對 「頁面可見、活躍時長」「組件可見時長」 的統計。

名詞解釋

其關係爲:

頁面可見及活躍時長統計

在我們的設計中,衡量一個頁面的停留及活躍時長需要兩個重要的指標:

只要能獲取到這四種狀態發生的時間戳,就可以按下圖所示方法,累加計算出頁面從載入到退出的可見和活躍時長:

計算頁面可見和活躍時長

獲取頁面可見性數據

Web Lifecycle

Google 公司在 2018 年 7 月份提出了一套用來描述網頁生命週期的 Page Lifecycle API 規範,本 SDK 便是基於這套規範來監聽頁面可見性變化的。規範指出,一個網頁從載入到銷燬的過程中,會通過瀏覽器的各種事件在以下六種生命週期狀態 (Lifecycle State) 之間相互轉化。

1fmbxS

生命週期狀態之間的轉化關係如下圖所示:

生命週期狀態轉化

由以上信息,我們可以得出頁面生命週期狀態和頁面可見狀態之間的映射關係:

Ql5DXJ

因此,我們只需要監聽頁面生命週期的變化並記錄其時間,就可以相應獲取頁面可見性的統計數據。

監聽頁面生命週期變化

在制定 Page Lifecycle 規範的同時,Google Chrome 團隊也開發了 PageLifecycle.js SDK, 實現了這套規範中描述的生命週期狀態的監聽,併兼容了 IE 9 以上的所有瀏覽器。出於易用性及穩定性的考慮,我們決定使用這個 SDK 來進行生命週期的監聽。由於 PageLifecycle.js 本身使用 JavaScript 編寫,我們爲其添加了類型定義並封裝爲了兼容 TypeScript 的 @byted-cg/page-lifecycle-typed SDK. PageLifecycle.js 的使用方法如下:

import lifecycleInstance, {
  StateChangeEvent,
} from "@byted-cg/page-lifecycle-typed";

lifecycleInstance.addEventListener("statechange"(event: StateChangeEvent) ={
  switch (event.newState) {
    case "active":
    case "passive":
      // page visible, do something
      break;
    case "hidden":
    case "terminated":
    case "frozen":
      // page invisible, do something else
      break;
  }
});

通過 PageLifecycle.js, 我們便可以監聽 statechange 事件來在頁面生命週期發生變化時獲得通知,並在生命週期狀態爲 activepassive 時標記頁面爲 visible 狀態,在生命週期狀態爲其他幾個時標記頁面爲 invisible 狀態,更新最後一次可見的時間戳,並累加頁面可見時間。

PageLifecycle.js 的缺陷

對於我們的需求來說,PageLifecycle.js 本身存在如下兩個缺陷。我們也針對這兩個缺陷做了一些改進。

獲取頁面活躍性數據

相較於頁面可見性,頁面活躍性的判斷要更加直截了當一些,直接通過以下方法判斷頁面狀態爲 active 還是 inactive 即可。

active 判斷標準

通過監聽一系列的瀏覽器事件,我們就可以判斷用戶是否在當前頁面上有活動。monitor-tracer 會監聽以下的六種事件:

yQifc1

一旦監聽到以上事件,monitor-tracer 就會將頁面標記爲 active 狀態,並記錄當前時間戳,累加活躍時長。

inactive 判斷標準

以下兩種情況下,頁面將會被標記爲 inactive 狀態:

頁面被標記爲 inactive 後,monitor-tracer 會記錄當前時間戳並累加活躍時長。

組件可見時長統計

統計組件級別活躍時長需要兩個條件,一是拿到所有需要統計的 DOM 元素,二是對這些 DOM 元素按照一定的標準進行監控。

獲取需要統計的 DOM 元素

監聽 DOM 結構變化

獲取 DOM 元素要求我們對整個 DOM 結構的改變進行監控。monitor-tracer 使用了 MutationObserver API, DOM 的任何變動,比如節點的增減、屬性的變動、文本內容的變動,都可以通過這個 API 得到通知。

概念上,它很接近事件,可以理解爲 DOM 發生變動就會觸發 MutationObserver 事件。但是,它與事件有一個本質不同:事件是同步觸發,也就是說,DOM 的變動立刻會觸發相應的事件,而 MutationObserver 則是異步觸發,DOM 的變動並不會馬上觸發,而是要等到當前所有 DOM 操作都結束才觸發。

這樣的設計是爲了應對 DOM 變動頻繁的特點,節省性能。舉例來說,如果文檔中連續插入 1000 個 <p></p> 標籤,就會連續觸發 1000 個插入事件,執行每個事件的回調函數,這很可能造成瀏覽器的卡頓。而 MutationObserver 完全不同,只在 1000 個標籤都插入結束後纔會觸發,而且只觸發一次。

MutationObserver API 的用法如下:

const observer = new MutationObserver(function (mutations, observer) {
  mutations.forEach(function (mutation) {
    console.log(mutation.target); // target: 發生變動的 DOM 節點
  });
});

observer.observe(document.documentElement, {
  childList: true, //子節點的變動(指新增,刪除或者更改)
  attributes: true, // 屬性的變動
  characterData: true, // 節點內容或節點文本的變動
  subtree: true, // 表示是否將該觀察器應用於該節點的所有後代節點
  attributeOldValue: false, // 表示觀察 attributes 變動時,是否需要記錄變動前的屬性值
  characterDataOldValue: false, // 表示觀察 characterData 變動時,是否需要記錄變動前的值。
  attributeFilter: false, // 表示需要觀察的特定屬性,比如['class','src']
});

observer.disconnect(); // 用來停止觀察。調用該方法後,DOM 再發生變動則不會觸發觀察器

標記需要監聽的元素

爲了在衆多 DOM 元素中找到需要監聽的元素,我們需要一個方法來標記這些元素。monitor-tracer SDK 規定,一個組件如果需要統計活躍時長,則需要爲其添加一個 monitor-pvdata-monitor-pv 屬性。在使用 MutationObserver 掃描 DOM 變化時,monitor-tracer 會將有這兩個屬性的 DOM 元素收集到一個數組裏,以供監聽。例如下面兩個組件:

<div monitor-pv='{ "event": "component_one_pv", "params": { ... } }'>
  Component One
</div>
<div>Component Two</div>

Component One 因爲添加了 monitor-pv 屬性,會被記錄並統計可見時長。而 Component Two 則不會。

babel-plugin-tracer 插件

如果需要監控的組件是通過一些組件庫(例如 Ant Design 或 ByDesign)編寫的,那麼爲其添加 monitor-pvdata-monitor-pv 這樣的自定義屬性可能會被組件自身過濾從而不會出現在最終生成的 DOM 元素上,導致組件的監控不生效。爲了解決類似的問題,我們開發了 @byted-cg/babel-plugin-tracer babel 插件。此插件會在編譯過程中尋找添加了 monitor-pv 屬性的組件,並在其外層包裹一個自定義的 <monitor></monitor> 標籤。例如:

import { Card } from 'antd';

const Component = () ={
  return <Card monitor-pv={{ event: "component_one_pv", params: { ... } }}>HAHA</Card>
}

如果不添加插件,那麼最終生成的 DOM 爲:

<div class="ant-card">
  <!-- ... Ant Design Card 組件 -->
</div>

可見 monitor-pv 屬性經過組件過濾後消失了。

而安裝 babel 插件後,最終編譯生成的 DOM 結構爲:

<monitor
  is="custom"
  data-monitor-pv='{ "event": "component_one_pv", "params": { ... } }'
>
  <div class="ant-card">
    <!-- ... Ant Design Card 組件 -->
  </div>
</monitor>

monitor-pv 屬性得到了保留,並且插件自動爲其添加了 data- 前綴,以應對 React 16 之前版本僅支持 data- 開頭的自定義屬性的問題;同時將傳入的對象使用 JSON.stringify 轉換成了 DOM 元素 attribute 唯一支持的 string 類型。

由於自定義標籤沒有任何樣式,所以包裹該標籤也不會影響到原有組件的樣式。monitor-tracer SDK 在掃描 DOM 元素後,會同時收集所有 <monitor></monitor> 標籤中的元素的信息,並對其包裹的元素進行監控。

判斷 DOM 元素可見性

對組件可見性的判斷可分爲三個維度:

判斷組件是否在瀏覽器 viewport 中

這裏我們使用了 IntersectionObserver API. 該 API 提供了一種異步檢測目標元素與祖先元素或 viewport 相交情況變化的方法。

過去,相交檢測通常要用到事件監聽,並且需要頻繁調用 Element.getBoundingClientRect 方法以獲取相關元素的邊界信息。事件監聽和調用 Element.getBoundingClientRect 都是在主線程上運行,因此頻繁觸發、調用可能會造成性能問題。這種檢測方法極其怪異且不優雅。

IntersectionObserver API 會註冊一個回調函數,每當被監視的元素進入或者退出另外一個元素時(或者 viewport),或者兩個元素的相交部分大小發生變化時,該回調方法會被觸發執行。這樣,我們網站的主線程不需要再爲了監聽元素相交而辛苦勞作,瀏覽器會自行優化元素相交管理。

IntersectionObserver API 的用法如下:

const observer = new IntersectionObserver(
  (entries) ={
    entries.forEach(function (entry) {
      /**
     entry.boundingClientRect // 目標元素的矩形區域的信息
     entry.intersectionRatio // 目標元素的可見比例,即 intersectionRect 佔 boundingClientRect 的比例,完全可見時爲 1,完全不可見時小於等於 0
     entry.intersectionRect // 目標元素與視口(或根元素)的交叉區域的信息
     entry.isIntersecting // 標示元素是已轉換爲相交狀態 (true) 還是已脫離相交狀態 (false)
     entry.rootBounds // 根元素的矩形區域的信息, getBoundingClientRect 方法的返回值,如果沒有根元素(即直接相對於視口滾動),則返回 null
     entry.target // 被觀察的目標元素,是一個 DOM 節點對象
     entry.time // 可見性發生變化的時間,是一個高精度時間戳,單位爲毫秒
     **/
    });
  },
  {
    threshold: [0, 0.25, 0.5, 0.75, 1], //該屬性決定了什麼時候觸發回調函數。它是一個數組,每個成員都是一個門檻值,默認爲 [0],即交叉比例 (intersectionRatio) 達到 0 時觸發回調函數
  }
);

observer.observe(document.getElementById("img")); // 開始監聽一個目標元素

observer.disconnect(); // 停止全部監聽工作

如果一個組件和 viewport 的相交比例小於某個值(默認爲 0.25),那麼這個組件就會被標記爲 invisible. 反之,如果比例大於某個值(默認爲 0.75),那麼這個組件就會被標記爲 visible

判斷組件 CSS 樣式是否可見

如果元素的 CSS 樣式設爲了 visibility: hiddenopacity: 0,那麼即使其與 viewport 的相交比例爲 1,對用戶來說也是不可見的。因此我們需要額外判斷目標元素的 CSS 屬性是否爲可見。

如果一個組件的樣式被設置爲了以下之一,那麼它就會被標記爲 invisible.

判斷頁面是否可見

當頁面不可見時,所有組件自然都不可見,因此在頁面爲 invisible 的狀態下,monitor-tracer 會將需監控的所有組件的狀態也標記爲 invisible

處理「觸發事件」的 SDK - monitor

monitor SDK 的定位

數據平臺 SDK 的單一的埋點上報方式,無法滿足我們開發中對 clean code 的極致追求

數據平臺的 SDK 往往只提供了上報埋點的函數式方法,雖然可以滿足我們的日常開發需求,但是並不能解決我們在寫埋點代碼時的兩大痛點:

我們希望埋點代碼可以輕易地添加、修改與刪除,並且對業務代碼沒有影響。因此,我們基於 TypeScript 開發對框架無感的 monitor SDK. 它支持逐個上傳多個埋點,並且接受返回埋點的函數,將其返回值上報;它提供了三種方式注入埋點,覆蓋了所有場景,將埋點與業務代碼完全分離。

可以看出,monitor 既是一個數據處理器,又是一個方法庫。更直觀一些,使用 monitor 後,我們的應用在上報埋點時的流程如下:

埋點上報流程

埋點由應用層發送給 monitor 後,monitor 首先會對數據進行處理,再調用數據平臺 SDK, 將埋點事件上報給數據平臺。

在對 monitor 有了初步瞭解後,這篇文章將主要講解 monitor 是如何通過以下三種埋點注入的方式,解耦業務邏輯與埋點邏輯的。

下面我們來看一下 monitormonitor-tracer SDK 具體的技術設計及實現方法。

三種埋點注入方式

類指令式

monitor 提供了類指令方式注入埋點。例如,下段代碼用 monitor-click 指令注入了埋點。在此按鈕被點擊 (click) 時,monitor-click 所對應的值,即一個事件,就會被上報。

// 指令式埋點示例
<Button
  monitor-click={JSON.stringify({
    type: "func_operation",
    params: { value: 3 },
  })}
>
  Click Me
</Button>

這是如何實現的呢?爲什麼僅僅給組件加了一個 monitor-click 屬性,monitor 就會在這個按鈕被點擊時上報埋點了呢?

實現與原理

其實,monitor SDK 在初始化時,會給當前的 document 對象加上一系列 Event Listeners, 監聽 hover click input focus 等事件。當監聽器被觸發時,monitor 會從觸發事件的 target 對象開始,逐級向上遍歷,查看當前元素是否有對應此事件的指令,如果有,則上報此事件,直至遇到一個沒有事件指令的元素節點。以下示意圖展示了類指令式埋點的上報流程:

類指令上報流程

逐級上報過程

以如下代碼爲例,當光標 hover 到 Button 時,document 對象上所安裝的監聽 hover 事件的函數便會執行。這個函數首先在 event.targetButton 上查找是否有與 hover 事件相關的指令(即屬性)。Buttonmonitor-hover 這個指令,此時函數便上傳此指令所對應的事件,即 { type: 'func_operation', params: { value: 1 }}

接下來,函數向上一層,到了 Button 的父元素,即 div, 重複上述過程,它找到了 data-monitor-hover 這個指令,便同樣地上報了對應的埋點事件。而到了 section 這一層,雖然其有 data-monitor-click 指令,但此指令並不對 hover 事件進行響應,因此,這個逐級上報埋點的過程結束了。

// 指令式埋點實現逐級上報
<section
        data-monitor-click={JSON.stringify({
        type: 'func_operation',
params: { value: 3 },
})}
>
<div
        data-monitor-hover={JSON.stringify({
        type: 'func_operation',
params: { value: 2 },
})}
>
<Button
        monitor-hover={JSON.stringify({
        type: 'func_operation',
params: { value: 1 },
})}
>
Click Me
</Button>
</div>
</section>

類指令式埋點注入適合簡單的埋點上報,清晰的與業務代碼實現了分離。但是如果我們需要在上報事件前,對所上報的數據進行處理,那麼這種方式就無法滿足了。並且,並不是所有的場景都可以被 DOM 事件所覆蓋。如果我想在用戶在搜索框輸入某個值時,上報埋點,那麼我就需要對用戶輸入的值進行分析,而不能在 input 事件每次觸發時都上報埋點。

裝飾器式

裝飾器本質上是一個高階函數。它接受一個函數,返回另一個被修飾的函數。因此,我們很自然地想到用裝飾器將埋點邏輯注入到業務函數,既實現了埋點與業務代碼的分離,又能夠適應於複雜的埋點場景。

下面的代碼使用了 @monitorBefore 修飾器。@monitorBefore 所接收的函數的返回值,即是要上報的事件。在 handleSearch 函數被調用的時候,monitor 會首先上報埋點事件,然後再執行 handleSearch 函數的邏輯。

// @monitorBefore 使用示例
@monitorBefore((value: string) =({
    type: 'func_operation',
    params: { keyword: value },
}))
handleSearch() {
    console.log(
        '[Decorators Demo]: this should happen AFTER a monitor event is sent.',
    );
}

return (
    <AutoComplete
        onSearch={handleSearch}
/>
)

@readonly 理解裝飾器原理

裝飾器是如何實現將埋點邏輯和業務邏輯相整合的呢?在我們詳細解讀 @monitorBefore 之前,讓我們先從一個常用的裝飾器 @readonly講起吧。

裝飾器應用於一個類的單個成員上,包括類的屬性、方法、getters 和 setters. 在被調用時,裝飾器函數會接收 3 個參數:

// @readonly裝飾器的代碼實現
readonly = (target, name, descriptor) ={
  console.log(descriptor);
  descriptor.writable = false;
  return descriptor;
};

上述代碼通過 console.log 輸出的結果爲:

代碼輸出結果

以我們常見的 @readonly 爲例,它的實現方法如上。通過在上述代碼中 log 出來 descriptor, 我們得知 descriptor 的屬性分別爲:

可見,@readonly 裝飾器將 descriptorwritable 屬性設置爲 false, 並返回這個 descriptor, 便成功將其裝飾的類成員設置爲只讀態。

我們以如下方式使用 @readonly 裝飾器:

class Example {
  @readonly
  a = 10;

  @readonly
  b() {}
}

@monitorBefore 的實現

@monitorBefore 裝飾器要比 @readonly 複雜一些,它是如何將埋點邏輯與業務邏輯融合,生成一個新的函數的呢?

首先,我們來看看下面這段代碼。monitorBefore 接收一個埋點事件 event 作爲參數,並返回了一個函數。返回的函數的參數與上面講過的 @readonly 所接收的參數一致。

// monitorBefore 函數源代碼
monitorBefore = (event: MonitorEvent) ={
  return (target: object, name: string, descriptor: object) =>
    this.defineDecorator(event, descriptor, this.before);
};
// @monitorBefore 使用方式
@monitorBefore((value: string) =({
    type: 'func_operation',
    params: { keyword: value },
}))
handleSearch() {
    console.log(
        '[Decorators Demo]: this should happen AFTER a monitor event is sent.',
    );
}

return (
    <AutoComplete
        onSearch={handleSearch}
/>
)

在編譯時,@monitorBefore 接收了一個 event 參數,並返回瞭如下函數:

f = (target: object, name: string, descriptor: object) =>
  this.defineDecorator(event, descriptor, this.before);

而後,編譯器又調用函數 f, 把當前的類、被裝飾的函數名稱與其屬性描述符傳給f. 函數 f 返回的 this.defineDecorator(event, descriptor, this.before) 會被解析爲一個新的 descriptor 對象,它的 value 會在運行時被調用,也就是說會在 onSearch 被觸發時所調用。

現在,讓我們詳細解讀 defineDecorator 函數是如何改變生成一個新的 descriptor 的吧。

// defineDecorator 函數源代碼
before = (event: MonitorEvent, fn: () => any) ={
  const that = this;
  return function (this: any) {
    const _event = that.evalEvent(event)(...arguments);
    that.sendEvent(_event);
    return fn.apply(this, arguments);
  };
};

defineDecorator = (
  event: MonitorEvent,
  descriptor: any,
  decorator: (event: MonitorEvent, fn: () => any) => any
) ={
  if (isFunction(event) || isObject(event) || isArray(event)) {
    const wrapperFn = decorator(event, descriptor.value);

    function composedFn(this: any) {
      return wrapperFn.apply(this, arguments);
    }

    set(descriptor, "value", composedFn);
    return descriptor;
  } else {
    console.error(
      `[Monitor SDK @${decorator}] the event argument be an object, an array or a function.`
    );
  }
};

monitorBefore = (event: MonitorEvent) ={
  return (target: object, name: string, descriptor: object) =>
    this.defineDecorator(event, descriptor, this.before);
};

defineDecorator 函數接收三個參數:

decorator 首先返回了一個函數 wrapperFn. 在被調用時,wrapperFn 會先上報埋點,然後執行 descriptor.value 的邏輯,即被裝飾的函數。

defineDecoratorcomposedFn 中, 我們用 wrapperFn.apply(this, arguments) 將調用被裝飾的函數時傳入的參數透傳給 wrapperFn

最後,我們將 composedFn 設爲 descriptor.value, 這樣,我們就成功生成了一個融合了埋點邏輯與業務邏輯的新函數。

裝飾器埋點注入方式十分整潔,能夠清晰地與業務代碼區分。不論是增添、修改還是刪除埋點,都無需顧慮會對業務代碼造成改動。但是其侷限性也是顯而易見的,裝飾器只能用於類組件,現在我們常用的函數式組件是無法使用裝飾器的。

React 鉤子

爲了能夠在函數式組件中,實現裝飾器埋點帶來的功能,我們還支持了埋點鉤子 useMonitor. 與裝飾器的原理相同,useMonitor 接收一個埋點函數,一個業務函數,返回一個新的函數將二者融合,既實現了代碼層面上的清晰分離,又覆蓋了全場景的埋點注入。

// useMonitor 源代碼
useMonitor = (fn: () => any, event: MonitorEvent) ={
  if (!event) return fn;
  const that = this;

  return function (this: any) {
    const _event = that.evalEvent(event)(...arguments);
    that.sendEvent(_event);

    return fn.apply(this, arguments);
  };
};

useMonitor 的實現較爲簡單,只是一個高階函數,不像裝飾器需要語法解析。它返回了一個函數,在被調用時會先上傳埋點事件,在執行業務邏輯。其使用方式如下:

// useMonitor 使用示例
const Example = (props: object) ={
  const handleChange = useMonitor(
    // 業務邏輯
    (value: string) ={
      console.log("The user entered", value);
    },
    // 埋點邏輯
    (value: string) ={
      return {
        type: "func_operation",
        params: { value },
      };
    }
  );

  return <Search onSearch={handleChange} />;
};

小結

上述三種埋點方式,覆蓋了所有使用場景。不論你是用 React, Vue, 還是原生 JavaScript, 不論你是使用類組件,還是函數式組件,不論你的埋點是否需要複雜的前置邏輯,monitor SDK 都提供了適合你的場景的使用方式。

技術棧

開發人員 Credits

參考鏈接

[3]: https://p-vcloud.byteimg.com/tos-cn-i-em5hxbkur4/804c4a9d7e83438ba3aed6845d50099e~tplv-em5hxbkur4-noop.image?width=2835&height=1541

[4]: https://p-vcloud.byteimg.com/tos-cn-i-em5hxbkur4/e5e21973cae44bb3b664c2b17dfbcd90~tplv-em5hxbkur4-noop.image?width=3360&height=1450

[5]: https://developers.google.com/web/updates/2018/07/page-lifecycle-api

[6]: https://p-vcloud.byteimg.com/tos-cn-i-em5hxbkur4/4e681b1967b749dea5302363745b8293~tplv-em5hxbkur4-noop.image?width=3280&height=2218

[7]: https://github.com/GoogleChromeLabs/page-lifecycle

[8]: https://bnpm.bytedance.net/package/@byted-cg/page-lifecycle-typed

[9]: https://bnpm.bytedance.net/package/@byted-cg/puzzle-router

[10]: https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver

[11]: https://bnpm.bytedance.net/package/@byted-cg/babel-plugin-tracer

[12]: https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver

[13]: https://developer.mozilla.org/zh-CN/docs/Glossary/Viewport

[14]: https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect

[15]: https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect

[17]: https://www.typescriptlang.org/

[18]: https://www.npmjs.com/package/wolfy87-eventemitter

[19]: lark://applink.feishu.cn/client/chat/open?openId=ou_e3a625ed9cd25fa9ed6cba3877590992

[20]: lark://applink.feishu.cn/client/chat/open?openId=ou_7197d8059c28e24828f73d6278ecfd02

[21]: http://tosv.byted.org/obj/cg-fe/monitor/monitor_doc / 埋點上報路徑示意圖. png

[22]: http://tosv.byted.org/obj/cg-fe/monitor/monitor_doc / 類指令式埋點的上報流程. png

[23]: http://tosv.byted.org/obj/cg-fe/monitor/monitor_doc/readonly_output.png?width=773&height=72

[24]: https://code.byted.org/cg/monitor

[27]: lark://applink.feishu.cn/client/chat/open?openId=ou_bad7c4f8d9542c7abf228ea574a16941

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/TcaOUBMBBEGQoQPAjYXb_Q