VSCode插件日志:用户行为追踪器的实现

362 阅读4分钟

一、背景

在 VS Code 插件开发中,通常我们如果要收集用户的行为信息,并根据它进行功能的优化,很大程度上都依赖于日志系统。

image.png

与普通的日志系统不一样的地方,在VSCode的插件中,日志通常分为两种,一种是指令日志,即用户调用了哪些指令,通常这种指令就是插件的某一个功能,它是系统集成的,属于被动触发;另一种是自定义日志,它可以在任意地方调用,由开发者自行决定在哪些关键点触发上报,它是自定义的,属于主动触发。

指令日志是为了减少自定义日志的代码编写,更加智能化和准确,一个实用的日志追踪系统,通常都会包含这两种日志。那么在VSCode中,如何搭建一个这样的日志追踪系统呢?


二、实现原理

1. 核心模块流程

image.png

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`;

image.png

这里我们可以通过枚举的方式获取,但由于枚举值写死不太好维护,每次新增或修改都要同步修改枚举值,因此我们可以通过读取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.registerTextEditorCommandvscode.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}`);
    }

image.png

如上,含有蓝色字体指令的属于指令调用日志,其他的则是自定义上报日志。

本地化存储后,可以根据需要,上报之后选择是否删除。


四、高级功能实现

以上只是实现基本的日志上报功能,根据项目的不同可以做成更多的扩展和其他高级功能的实现,如:

  1. 性能热点分析
  2. 智能日志分析
  3. 日志分级处理
  4. 分布式追踪集成
  5. 安全审计日志

通过以上技术方案实现的日志追踪器,不仅能满足基本的调试需求,更能为插件质量保障、功能优化提供数据支撑。