【监控】基于注释的日志上报方案

基于注释的日志上报方案

相关文章

基于注释的日志上报方案


一、日志采集发展史

直接在代码中插入埋点事件

优势:没有技术成本、无脑埋
劣势:耦合度高、影响代码可读性、阻碍业务迭代、增加维护成本、引入错误风险

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<div>
<button @click="handleClick">下单</button>
<button @click="handleExposure">打开弹窗</button>
</div>
</template>

<script>
import report from '@/utils/report.js'

export default {
methods: {
handleClick() {
// 处理点击事件的逻辑
const par = { ... }
report.click('eventName', par)
},
handleExposure() {
// 处理曝光事件的逻辑
const par = { ... }
report.exposure('eventName', par)
}
}
}
</script>

通过指令的形式进行埋点

优势:埋点与业务相对隔离
劣势:耦合度高、影响代码可读性、限定技术栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// main.js

import Vue from 'vue';
import App from './App.vue';
import report from '@/utils/report.js'

// 全局注册自定义指令
Vue.directive('track-click', {
inserted(el, binding) {
el.addEventListener('click', function() {
// 获取传递的参数
const { eventName, par } = binding.value;
report.click(eventName, par)
});
}
});

new Vue({
render: h => h(App),
}).$mount('#app');
1
2
3
4
5
6
7
8
<template>
<div>
<!-- 使用指令,并传递参数 -->
<button v-track-click="{eventName: 'xxx', par}">下单</button>
</div>
</template>
<script></script>
<style scoped></style>

通过装饰器进行埋点

优势:进一步解耦,以注解的形式实现
劣势:只能作用于类的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import React from 'react';
import report from '@/utils/report.js'

// 定义一个埋点装饰器,用于装饰方法
function withClickTracking(eventName) {
return function(target, key, descriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args) {
report.click(eventName, args)
return originalMethod.apply(this, args);
};
return descriptor;
};
}

// 定义一个组件
class MyComponent extends React.Component {

@withClickTracking('eventName')
handleClick() {
// 业务逻辑
}

render() {
return (
<div>
<button onClick={this.handleClick.bind(this)}>下单</button>
</div>
);
}
}

export default MyComponent;

二、埋点方案复盘

为了解决上述问题并确保通用性,我们可以尝试通过注释的形式来实现埋点上报功能。通过在需要执行上报的方法上添加特定格式的注释,我们可以在编译过程中解析这些注释,并将对应的上报逻辑添加到方法体内。这种方式不限制于特定的技术栈、语法或框架,同时也能够减少代码耦合,提高代码的可维护性和通用性。

大致过程

  • 在需要执行上报的方法上添加注释,指定一个约定的上报方法名,以标注事件类型,并可传递固定参数(如事件编码)。
  • 编译过程中将带有上报注释的方法添加到对应方法体内。同时,将函数的参数对象传递给上报方法,确保上报方法具有完整的作用域。
  • 提供一个全局上报函数,建议将其绑定到 window 对象上。由于不同技术栈的全局方法注入形式不同,此举有助于保持一致性。特别是在函数式组件中,没有 this 对象,需要手动导入实例或使用上下文对象。
  • 实现日志上报

三、详细实现

添加注释

  • 选项式api

    1
    2
    3
    4
    5
    6
    export default {
    methods: {
    // @report('click', 'bbb')
    addTodo() {},
    },
    }
  • 组合式api

    1
    2
    3
    4
    5
    6
    export default defineComponent({
    setup() {
    // @report('click', 'bbb')
    const addTodo = () => {};
    }
    })

编译

详细实现细节参考之前的两篇文章

提取js文件函数注释
提取vue和jsx文件函数注释

实现结果打印如下

在上述文档中,我们介绍了如何在不同文件和语法环境下提取注释,以及如何提取带有注释的方法名。接下来,我们将在每个方法体内追加一个相应的函数。

追加日志上报函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const handleFunction = (node, name, leadingComments = []) => {
// 检查是否有注释
if (leadingComments.length > 0) {
// 解析注释内容
const comments = leadingComments
.map((comment) =>
comment.value
.trim()
.split("\n")
.map((line) => line.trim().replace(/^\*+\s*/, ""))
.filter(Boolean)
)
.flat();
// 查找带有 @report 标记的注释
const reportComment = comments.find(
(comment) => comment.indexOf("@report") !== -1
);
if (reportComment) {
const bodyNodes = node.body.body;

// 在方法体内添加上报逻辑
const reportStatement = babel.template.statement.ast(
`window._g_report_ && window._g_report_(arguments, ${results.args})`
);
bodyNodes.unshift(reportStatement);
}
}
};

执行效果体验如下

至此,我们的核心工作已经完成,接下来把它放在编译时期来实现把,例如使用webpack

使用webpack插件自动化处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// ReplaceFilePlugin.js
const fs = require('fs');
const path = require('path');

class ReplaceFilePlugin {
constructor(options) {
this.options = options;
}

apply(compiler) {
compiler.hooks.emit.tapAsync('ReplaceFilePlugin', (compilation, callback) => {
// 获取输出目录路径
const outputPath = compilation.options.output.path;

// 循环遍历所有资源文件
for (const filename in compilation.assets) {
if (filename.match(/\.(js|vue|jsx)$/)) {
const asset = compilation.assets[filename];
const filepath = path.resolve(outputPath, filename);
let content = asset.source(); // 获取文件内容

// 调用公共函数处理文件内容
content = this.options.process(content);

// 将处理后的内容覆盖原文件
fs.writeFileSync(filepath, content);
}
}

callback();
});
}
}

module.exports = ReplaceFilePlugin;

解析器实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// parseAST.js
const { parse } = require("@babel/parser"); // 导入 Babel 的解析器
const { parse: vueParse } = require("@vue/compiler-sfc"); // 导入 Vue 单文件组件的解析器
const fs = require("fs"); // 导入文件系统模块

// 检查节点类型是否为函数表达式或箭头函数表达式
const isFunctionExpression = (type) => {
return type === "FunctionExpression" || type === "ArrowFunctionExpression";
};

// 解析 AST,并处理函数节点
const parseAST = (node, handleFunction) => {
// 文件节点,遍历子节点
if (node.type === "File") {
node.program.body.forEach((n) => parseAST(n, handleFunction));
// 导出默认声明节点,解析声明内容
// export default
} else if (node.type === "ExportDefaultDeclaration") {
parseAST(node.declaration, handleFunction);
// 函数调用节点,解析参数
// add(2, 3);
} else if (node.type === "CallExpression") {
node.arguments.forEach((arg) => parseAST(arg, handleFunction));
// 对象方法节点,处理函数节点或 setup 方法节点
// const obj = {
// method() {
// // method body
// }
// };
// obj中的method就是ObjectMethod
} else if (node.type === "ObjectMethod") {
if (node.key.name === "setup") {
// 如果是 setup 方法节点,遍历函数体
node.body.body.forEach((n) => parseAST(n, handleFunction));
} else {
// 其他情况处理为普通函数节点
handleFunction(node, node.key.name, node.leadingComments);
}
// 对象表达式节点,遍历属性节点
// const obj = {
// key1: value1,
// key2: value2,
// };
// { key1: value1, key2: value2, } 就是 ObjectExpression;
} else if (node.type === "ObjectExpression") {
node.properties.forEach((property) => parseAST(property, handleFunction));
// 函数声明节点,处理函数节点
// function myFunction() {
// // 函数体
// }
// myFunction就是FunctionDeclaration
} else if (node.type === "FunctionDeclaration") {
handleFunction(node, node.id.name, node.leadingComments);
// 变量声明节点,处理初始化表达式节点
// var x = 10;
// let y = 20;
// const z = 30;
// x\y\z就是VariableDeclaration
} else if (node.type === "VariableDeclaration") {
const declaration = node.declarations[0];
// 如果是函数表达式节点,处理为函数节点
if (declaration.init && isFunctionExpression(declaration.init.type)) {
handleFunction(declaration.init, declaration.id.name, node.leadingComments);
// 其他情况继续解析初始化表达式
} else {
parseAST(declaration.init, handleFunction);
}
// 对象属性节点,处理属性值为函数表达式的情况
// const obj = {
// key1: value1,
// key2: value2,
// // ...
// };
// obj中的key1\key2就是ObjectProperty
} else if (node.type === "ObjectProperty") {
// 如果是函数表达式节点,处理为函数节点
if (isFunctionExpression(node.value.type)) {
handleFunction(node, node.key.name, node.leadingComments);
// 如果是对象表达式节点,递归解析对象属性
} else if (node.value.type === "ObjectExpression") {
node.value.properties.forEach((property) =>
parseAST(property, handleFunction)
);
}
}
};

// 获取 Vue 单文件组件的脚本内容
const getScriptContent = (descriptor) => {
return descriptor.script
? descriptor.script.content
: descriptor.scriptSetup
? descriptor.scriptSetup.content
: descriptor.source
? descriptor.source
: "";
};

// 导出函数,用于解析 Vue 文件并提取函数注释
module.exports.parseVueComments = (content, handleFunction) => {
// 解析 Vue 文件
const { descriptor } = vueParse(content);
// 使用 Babel 解析 Vue 文件的脚本内容,并使用 Vue JSX 插件处理代码
const scriptContent = parse(getScriptContent(descriptor), {
sourceType: "module",
plugins: ["jsx"],
});
// 解析 AST 并提取函数注释
parseAST(scriptContent, handleFunction);
return scriptContent
};

使用插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack.config.js
const { parseVueComments } from './parseAST'
const ReplaceFilePlugin = require('./ReplaceFilePlugin');

module.exports = {
// 其他配置...
plugins: [
// 其他插件...
new ReplaceFilePlugin({
process: (content) => {
return parseVueComments(content);
}
})
]
};

上报方法

详细实现细节参考之前的一篇文章

日志上报的方法


【监控】基于注释的日志上报方案
https://www.cccccl.com/20240202/监控/基于注释的日志上报方案/
作者
Jeffrey
发布于
2024年2月2日
许可协议