一、背景
在 VS Code 插件开发中,通常我们如果要收集用户的行为信息,并根据它进行功能的优化,很大程度上都依赖于日志系统。
与普通的日志系统不一样的地方,在VSCode的插件中,日志通常分为两种,一种是指令日志,即用户调用了哪些指令,通常这种指令就是插件的某一个功能,它是系统集成的,属于被动触发;另一种是自定义日志,它可以在任意地方调用,由开发者自行决定在哪些关键点触发上报,它是自定义的,属于主动触发。
指令日志是为了减少自定义日志的代码编写,更加智能化和准确,一个实用的日志追踪系统,通常都会包含这两种日志。那么在VSCode中,如何搭建一个这样的日志追踪系统呢?
二、实现原理
1. 核心模块流程
2. 核心原理
指令监听的核心原理其实很简单,就是劫持系统的指令,重写它从而实现指令的监听,同时过滤掉VSCode内置命令。
日志上报方式分为两种,一种是先记录在本地文件或内存中,等待合适的时机再批量上报;另一种是实时上报,这里我们采用前者。
三、实现步骤详解
1. 配置化
首先需要在package.json中做好配置,如下:
"contributes": {
"configuration": {
"title": "Command Tracker",
"properties": {
"commandTracker.logToFile": {
"type": "boolean",
"default": true,
"description": "将日志保存到工作区 .vscode/track-test.log 文件中"
},
"commandTracker.ignoreBuiltinCommands": {
"type": "boolean",
"default": true,
"description": "忽略VSCode内置命令"
}
}
},
},
2. 基础日志搭建
基础日志的核心,初始化的时候将写入流logStream打开,同时定义两个核心方法,一个是commandLog指令日志,另一个是customLog自定义日志。
import * as vscode from "vscode";
import * as fs2 from "fs/promises";
import { Utils } from "./utils";
import { FileIO } from "./fileIO";
import * as packageJson from "../../package.json";
const path = require("path");
/**
* 日志追踪基础类
* 主要用于记录用户的命令行为
*/
export class Tracker {
private logStream?: fs2.FileHandle;
private commandMap: any = {};
constructor(private config: vscode.WorkspaceConfiguration) {}
async initialize(logPath: string, configFilePath: string) {
// 是否为插件xxx的目标项目
// const files = await FileIO.getFiles("**/" + configFilePath);
// if (!files || files.length === 0) {
// console.log("not xxx project");
// return;
// }
// 初始化日志输出方式
if (this.config.get("logToFile")) {
const logFilePath = path.join(
vscode.workspace.rootPath || "",
".vscode/log"
);
await fs2.mkdir(logFilePath, { recursive: true });
this.logStream = await fs2.open(
path.join(logFilePath, logPath),
"a"
);
}
this.commandMap = getCommandMap();
}
/**
* 指令日志
* @param command 命令名称
* @param args 命令参数
*/
async commandLog(command: string, args: any[]) {
try {
const timestamp = Utils.formatTime(new Date());
const message = `[${timestamp}] CMD: ${command}[${this.commandMap[command]}]\n`;
// console.log("log========================", message);
// 多路日志输出
if (this.logStream) {
await this.logStream.write(message);
}
// 日志发送
// log.send(message, logType);
} catch (e) {
console.error("log e", e);
}
}
/**
* 自定义日志
* @param trackName 命令名称
* @param args 命令参数
*/
async customLog(trackName: string, args: any[] = []) {
try {
const timestamp = Utils.formatTime(new Date());
const message = `[${timestamp}] CMD: ${trackName} | ARGS: ${JSON.stringify(
args
)}\n`;
// 多路日志输出
if (this.logStream) {
await this.logStream.write(message);
}
// 日志发送
// log.send(message, logType);
} catch (e) {
console.error("customLog e", e);
}
}
dispose() {
this.logStream?.close();
this.commandMap = {};
}
}
在记录指令日志时,通常需要解析指令command的含义,如下:
const message = `[${timestamp}] CMD: ${command}[${this.commandMap[command]}]\n`;
这里我们可以通过枚举的方式获取,但由于枚举值写死不太好维护,每次新增或修改都要同步修改枚举值,因此我们可以通过读取package.json中的命令,动态读取的方式获取更智能一些,如下:
const getCommandMap = () => {
const commandMap = {};
const commandList = packageJson?.contributes?.commands || [];
commandList.forEach((item) => {
const command = item?.command;
const title = item?.title;
commandMap[command] = title;
});
return commandMap;
}
3. 命令劫持的实现
首先要分析插件中使用命令的方式,也就是extension.ts文件中是如何声明命令的。
这里我们使用到了两种命令声明模式,分别为vscode.commands.registerTextEditorCommand和vscode.commands.registerCommand。
因此,在劫持命令的时候,我们需要分别对这两个命令进行劫持处理。
劫持命令的原理:在启动时重新赋值了两个劫持后包装的命令,并且在卸载时又将两个原来的命令重新赋值回去。如下:
// vscode-ui.ts
...
/**
* 日志初始化
* 劫持命令注册系统
* @param context
*/
static initLog(context: vscode.ExtensionContext) {
const commandTracker = vscode.workspace.getConfiguration('commandTracker');
const tracker = new Tracker(commandTracker);
const logPath = 'xxx.log';// 日志存储文件
const configFilePath = 'xxx.json';// 用于识别插件目标项目,否则会对所有的项目都会生效
tracker.initialize(logPath, configFilePath).then(() => {
const {
proxyRegisterCommand,
proxyRegisterTextEditorCommand,
originalRegister,
originalTextEditorRegister
} = createCommandProxy(tracker);
// 劫持两种注册方式
(vscode.commands as any).registerCommand = proxyRegisterCommand;
(vscode.commands as any).registerTextEditorCommand = proxyRegisterTextEditorCommand;
context.subscriptions.push({
dispose: () => {
(vscode.commands as any).registerCommand = originalRegister;
(vscode.commands as any).registerTextEditorCommand = originalTextEditorRegister;
tracker.dispose();
}
});
});
return tracker;
}
...
接下来,我们一起来看一下劫持命令后,具体实现了什么。即createCommandProxy的具体实现方式,如下:
function createCommandProxy(tracker: Tracker) {
const originalRegister = vscode.commands.registerCommand;
const originalTextEditorRegister = vscode.commands.registerTextEditorCommand;
// 通用包装函数
const createWrappedCallback = (command: string, callback: Function) => {
return async (...args: any[]) => {
// 过滤内置命令(根据配置)
const ignoreBuiltin = vscode.workspace
.getConfiguration("commandTracker")
.get("ignoreBuiltinCommands");
if (ignoreBuiltin && command.startsWith("_")) {
return callback(...args);
}
// 记录指令日志
await tracker.commandLog(command, args);
try {
return await callback(...args);
} catch (error) {
// 自定义日志
await tracker.customLog(`${command}.error`, [error?.message || String(error)]);
throw error;
}
};
};
// 代理 registerCommand
const proxyRegisterCommand = (
command: string,
callback: (...args: any[]) => any
) => {
return originalRegister(command, createWrappedCallback(command, callback));
};
// 代理 registerTextEditorCommand
const proxyRegisterTextEditorCommand = (
command: string,
callback: (
textEditor: vscode.TextEditor,
edit: vscode.TextEditorEdit,
...args: any[]
) => void
) => {
const wrapped = (
textEditor: vscode.TextEditor,
edit: vscode.TextEditorEdit,
...args: any[]
) => {
return createWrappedCallback(command, callback)(
textEditor,
edit,
...args
);
};
return originalTextEditorRegister(command, wrapped);
};
return {
proxyRegisterCommand,
proxyRegisterTextEditorCommand,
originalRegister,
originalTextEditorRegister,
};
}
核心函数createWrappedCallback本质上就是处理一下日志上报,同时过滤点vscode内置的命令,当然你也可以干别的事,原理都差不多。
4. 追踪行为日志
监听用户行为方式分为两种:分别为指令调用和自定义上报。
1)指令调用
实现非入侵式代码监听。
首先,在VSCode插件启动时需要声明调用一下初始化命令initLog,用户调用指令就会自动监听,不再需要手动一个个去写日志上报指令,从而大大节省了人工精力以及繁琐的日志代码,实现非入侵式的编码监听用户行为。如下:
// 劫持全局指令,记录用户行为
export async function activate(context: vscode.ExtensionContext) {
....
const tracker = VSCodeUI.initLog(context);
...
}
2)自定义上报
实现扩展性更强的上报方式。
在很多时候,我们需要监听用户特定的行为,尤其是在关键节点,往往需要监听用户或系统的反馈,比如接口的成功与否,用户采用什么方式,输入了什么,以及链路分析等等。
try{
...
// 上报自定义日志
tracker.customLog('扫描上传成功', Object.keys(res));
} catch (e) {
Message.showMessage(`上传失败:${e}`, MessageType.ERROR);
// 上报自定义日志
tracker.customLog(`上传失败:${e}`);
}
如上,含有蓝色字体指令的属于指令调用日志,其他的则是自定义上报日志。
本地化存储后,可以根据需要,上报之后选择是否删除。
四、高级功能实现
以上只是实现基本的日志上报功能,根据项目的不同可以做成更多的扩展和其他高级功能的实现,如:
- 性能热点分析
- 智能日志分析
- 日志分级处理
- 分布式追踪集成
- 安全审计日志
通过以上技术方案实现的日志追踪器,不仅能满足基本的调试需求,更能为插件质量保障、功能优化提供数据支撑。