手把手教你搭建一个无框架埋点体系

427次阅读  |  发布于3年以前

背景

埋点体系构成

一般来说,一个完整的埋点体系由以下三个部分构成:

埋点上报是将应用层事件上传至上层平台的过程。比方说,在某购物网站上,用户点击了「收藏」按钮,此时,一个点击事件就生成了,这一事件会被上报至一个数据分析平台。这样,相关的数据分析师、产品经理、运营等同学便可以在数据分析平台,通过这些上报的事件数据分析,得出应用中可以优化的方方面面。由此可见,埋点上报是每个产品走向卓越的重要一环。

通过以上描述,我们认识了埋点上报过程的两大主角:应用与数据分析平台。从前端技术的角度来说,我们通常还需要第三个角色的助攻,那就是数据平台 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) 之间相互转化。

生命周期状态 描述
active 网页可见,且具有焦点
passive 网页可见,但处于失焦状态
hidden 网页不可见,但未被浏览器冻结,一般由用户切换到别的 tab 或最小化浏览器触发
frozen 网页被浏览器冻结(一些后台任务例如定时器、fetch 等被挂起以节约 CPU 资源)
terminated 网页被浏览器卸载并从内存中清理。一般用户主动将网页关闭时触发此状态
discarded 网页被浏览器强制清理。一般由系统资源严重不足引起

生命周期状态之间的转化关系如下图所示:

生命周期状态转化

由以上信息,我们可以得出页面生命周期状态和页面可见状态之间的映射关系:

生命周期状态 可见状态
active passive visible
hidden terminated frozen discarded invisible

因此,我们只需要监听页面生命周期的变化并记录其时间,就可以相应获取页面可见性的统计数据。

监听页面生命周期变化

在制定 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 会监听以下的六种事件:

事件 描述
keydown 用户敲击键盘时触发
mousedown 用户点击鼠标按键时触发
mouseover 用户移动鼠标指针时触发
touchstart 用户手指接触触摸屏时触发(仅限触屏设备)
touchend 用户手指离开触摸屏时触发(仅限触屏设备)
scroll 用户滚动页面时触发

一旦监听到以上事件,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 个 `标签,就会连续触发 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 都提供了适合你的场景的使用方式。

参考链接

[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

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8