微前端接入Sentry的不完美但已尽力的实践总结

前言

这是一篇由浅入深地讲述如何对qiankun实现的微前端项目接入Sentry的文章。在这篇文章中,我会列举描述两个接入方案,然后再细致地分析方案中涉及到的原理。

通过这篇文章,你将学会:

  1. 两种给自己的微前端项目接入Sentry的方案以及这两种方案的优缺点

  2. 学习如何上传sourcemap和处理以让Sentry后台能精准定位错误

  3. 了解qiankun的部分深入知识点

  4. 了解Sentry的部分深入知识点

本文所使用的Sentry的客户端版本为7.29.0,服务端版本为23.1.0qiankun版本为2.7.5

如果不了解微前端,可看我之前的一篇金选文章给 vue-element-admin 接入 qiankun 的微前端开发实践总结 。

在了解两个方案之前,我想让你知道以下三点

  1. 对于为前端的监控系统,我们希望他能做到以下基本点

    对于第 1 点,目前我探索出的两种方案都不能完美做到,都留有着瑕疵。大家看看下面的方案分析来判断这点瑕疵是否对自己的项目影响大,从而决定是否使用以下方案。

    对于第 2 点,会在本文的 如何处理sourcemap 章节中分析如何实现,因为两个方案中的处理方式都一致,而且这个不是本文的重点。

    1. Sentry所报的Error(错误)Transaction(性能)需要根据其所属的应用进行区分上报。

    2. 查看Error时,可根据错误栈和sourcemap映射得知Error是所发生在哪个应用的哪段源码上。

  2. 本文两个接入方案中,只在主应用里接入sentry依赖,子应用不作接入处理(因为主应用和子应用同时接入sentry依赖会报错,原因可看此处)。

  3. 本文项目中的主应用和子应用都带各自的Release(版本),且Release不是固定的,而是随着迭代变化的。


接下来开始依次展示两个方案

方案一(次要推荐)

思路分析

在每个IssueTransaction上报之前,都分别会触发Sentry中的beforeSendbeforeSendTransaction钩子函数。我们来看一下官方对这两个钩子的代码示例:

Sentry.init({
  dsn: "xxx",
  // Called for message and error events
  beforeSend(event) {
    // Modify or drop the event here
    if (event.user) {
      // Don't send user's email address
      delete event.user.email;
    }
    return event;
  },
  // Called for transaction events
  beforeSendTransaction(event) {
    // Modify or drop the event here
    if (event.transaction === "/unimportant/route") {
      // Don't send the event to Sentry
      return null;
    }
    return event;
  },
});

从上面例子可知,我们可以在钩子函数中修改event的属性。我们再来通过console.log看一下error eventtransaction event是长什么样的:

error event

微前端接入Sentry的不完美但已尽力的实践总结_第1张图片 image.png

transaction event

微前端接入Sentry的不完美但已尽力的实践总结_第2张图片 image.png

可以观察到,两种event都带有release属性。当event发送到服务端时,服务端会根据releaseevent分到不同的版本下。

那么我们可以有这样的设计思路:

  1. Sentry的后台管理平台上建立一个主应用,一个主应用有多个release。多个release分别对应子应用和主应用的不同版本。如下所示:

    微前端接入Sentry的不完美但已尽力的实践总结_第3张图片 image.png
  2. 在钩子函数中通过设计代码逻辑判断出event来自哪个应用,然后修改event.release。让event在服务端分配到对应的release上。

对于上述思路中,有人会提出疑问:为什么不把主应用和子应用放在不同的Project(项目)里,而是放在同一Project的不同Release(版本)里?

在目前方案一中,我还不能成功实现让event存放到不同Project里。曾经试过按照使用 Sentry 做异常监控 - 如何优雅的解决 Qiankun 下 Sentry 异常上报无法自动区分项目的问题 ?这篇文章的思路在实现,但可能是因为版本不一致,导致我在发送event到对应的dsn时,子应用都响应失败,说是JSON格式不正确,如下所示:

微前端接入Sentry的不完美但已尽力的实践总结_第4张图片 image.png

因此就搁置了这种方式。如果读者觉得遗憾,可以去看本文中的方案二(),在方案二中成功实现把主应用和子应用都拆分到不同Project里,且IssueTransaction都可以放到所属应用的Project里。

代码实现

纸上得来终觉浅,绝知此事要躬行。接下来通过代码来展示如何实现方案一的思路,整个实现过程可以总结成以下三步:

  1. 让被接入主应用的sentry也能监听子应用的错误

    这里主要针对以vue为框架的子应用,vue中涉及到组件的错误,例如:

    上面这些错误都是由Vue.config.errorHandler来进行捕获处理的,且处理后是不会被window.onerror再次捕获的。我们也可以从以下[email protected]:src/core/util/error.ts中的源码看出:

    export function handleError(err: Error, vm: any, info: string) {
      // Deactivate deps tracking while processing error handler to avoid possible infinite rendering.
      // See: https://github.com/vuejs/vuex/issues/1505
      pushTarget();
      try {
        if (vm) {
          // 以下while操作中,会通过$parent获取组件到根组件的所有errorCaptured钩子函数。然后把依次把捕获到的error作为形参传入执行。
          // 直至其中有errorCaptured返回true或最终由globalHandleError执行。
          let cur = vm;
          while ((cur = cur.$parent)) {
            const hooks = cur.$options.errorCaptured;
            if (hooks) {
              for (let i = 0; i < hooks.length; i++) {
                try {
                  const capture = hooks[i].call(cur, err, vm, info) === false;
                  if (capture) return;
                } catch (e: any) {
                  globalHandleError(e, cur, "errorCaptured hook");
                }
              }
            }
          }
        }
        globalHandleError(err, vm, info);
      } finally {
        popTarget();
      }
    }
    
    // globalHandleError内部其实就是调用了Vue.config.errorHandler,因为有try~catch包裹,因此错误不会被window.onerror捕获。
    // 即使没有定义Vue.config.errorHandler,由于handleError在执行errorCaptured链是也是用try~catch包裹,因此错误也不会被window.onerror捕获。
    function globalHandleError(err, vm, info) {
      if (config.errorHandler) {
        try {
          return config.errorHandler.call(null, err, vm, info);
        } catch (e: any) {
          // if the user intentionally throws the original error in the handler,
          // do not log it twice
          if (e !== err) {
            logError(e, null, "config.errorHandler");
          }
        }
      }
      logError(err, vm, info);
    }

    sentry在初始化过程中,针对以vue为框架的应用是会使用Vue.config.errorHandler进行错误捕捉的。但由于在我们的方案中,sentry依赖只在主应用中被接入,子应用不会被接入,从而导致以vue为框架的子应用中,子应用的Vue.config.errorHandler没有被sentry使用,从而导致无法捕获和上传到这些子应用的vue方面的错误。

    想要捕获和上传子应用的错误,我们需要获取子应用的Vue.config.errorHandler,然后交给sentry进行处理。

  • 生命周期钩子里的错误、

  • 自定义事件处理函数内部的错误,例如在子组件中$emit('xxx')触发父组件的事件

  • v-on DOM 监听器内部抛出的错误

  • method中的处理函数抛出的错误或者返回的Promise链中的错误

获取子应用的release

在加载子应用之前,主应用是不能预先知道子应用的release的。因为子应用的release是随着其迭代发版而变化的,其release只能在主应用加载该子应用时,子应用把release存放在window的某个属性中,主应用在读取window这个属性时得知。

beforeSendbeforeSendTransaction中通过逻辑判断event来自哪个应用,并修改其release属性为子应用的release

这一步主要在于如何设计判断逻辑,error eventtransaction event的判断逻辑不一样,具体逻辑会放在下文去展示。

微前端接入Sentry的不完美但已尽力的实践总结_第5张图片

1. 让被接入主应用的Sentry也能监听子应用的错误

在上面 的分析中知道,我们要我们需要获取子应用的Vue.config.errorHandler,然后交给sentry进行处理。其实我们可以在主应用中创建一个Sentry初始化vue子应用的函数sentryInitForVueSubApp,然后把该方法传给子应用,让子应用在初次加载时调用,如下所示:

// 在主应用中的逻辑
loadMicroApp({
  name: "vue3 app",
  entry: `//${location.host}/vue3-app/`,
  container: "#app-vue3",
  activeRule: "/app-vue3/index",
  props: {
    // 在props中把该初始化方法传给子应用
    sentryInit: sentryInitForVueSubApp,
  },
});

// 在Vue2子应用的逻辑
// 在子应用的mount钩子函数中调用
export async function mount(props) {
  props.sentryInit?.(Vue, {
    tracesSampleRate: 1.0,
    logErrors: true,
    attachProps: true,
  });
  instance = new Vue({
    //...
  }).$mount(props.container ? props.container.querySelector("#app") : "#app");
}

// 在Vue3子应用的逻辑
// 在子应用的mount钩子函数中调用
export async function mount(props: any) {
  const { container, sentryInit } = props;
  instance = createApp(App).use(pinia);
  sentryInit?.(instance, {
    tracesSampleRate: 1.0,
    logErrors: true,
    attachProps: true,
  });
  instance.mount(container ? container.querySelector("#app") : "#app");
}

子应用调用sentryInitForVueSubApp时,其实就是调用在主应用中的sentry来给子应用的Vue进行监听处理。接下来我们来看看sentryInitForVueSubApp的函数要怎么编写:

import { attachErrorHandler, createTracingMixins } from "@sentry/vue";

/**
 *  app用于传入Vue或者Vue示例
 *  options用于传入子应用的sentry配置
 */
function sentryInitForVueSubApp(app, options) {
  // attachErrorHandler中,sentry会获取app.config.errorHandler进行处理
  attachErrorHandler(app, options);

  if ("tracesSampleRate" in options || "tracesSampler" in options) {
    app.mixin(
      // createTracingMixins用于在event中追加关于vue的信息,例如从抛出错误的组件到根组件形成的组件轨迹等
      // 即使一个页面用了多个相同的组件,这种信息也能让我们快速定位错误抛自哪个组件实例上
      createTracingMixins({
        ...options,
        ...options.tracingOptions,
      })
    );
  }
}

其实sentryInitForVueSubApp中的代码大多取自于sentry源码中的vueInit方法,大家也可以阅读这部分源码。

2. 获取子应用的release

本文对应用中release的值和格式没有硬性规定。release可以是在CI过程中生成,也可以是自己在代码中写死。本文采用是写死的方式:每个应用的release都是取自package.json里的nameversion,以{name}@{version}的形式生成。然后以process.env.VUE_APP_RELEASEprocess.env.REACT_APP_RELEASE注入到子应用中

vue-clicreate-react-app生成的项目中,分别只有以 VUE_APP_REACT*APP*开头的变量才会被 webpack.DefinePlugin 静态嵌入到客户端侧的包中,具体可看vue-cli#在客户端侧代码中使用环境变量和CRA#Adding Custom Environment Variables。

当子应用被主应用加载时,通过通信方式把自身的release发送给主应用,主应用接收后存放到window的属性中,如下所示:

// 主应用逻辑
// 事先声明window["$micro_app_release"],值为一个空对象
window["$micro_app_release"] = {};

// 子应用逻辑
// 在bootstrap钩子函数中把release放到window["$micro_app_release"]里
export async function bootstrap() {
  // process.env.VUE_APP_NAME为package.json里的name,此处把微应用的名称作为key值
  window["$micro_app_release"][process.env.VUE_APP_NAME] =
    process.env.VUE_APP_RELEASE;
}

疑问解答

  1. 子应用是否可以直接通过window[app_name]=releaserelease存放在window的一级属性里呢?

    结论是不可以,必须放在二级属性里。在没有指定sandbox参数或sandbox不为false的情况下,qiankun会为每个子应用生成一个沙箱,这个沙箱作用在于修改每个子应用的js作用域中的window的指向。当子应用运行在处于支持ES6-Proxy的浏览器环境下,其window不像以往一样指向作用域链头部的全局对象Window,而是指向一个Proxy实例。

    这个Proxy实例的目标对象(即new Proxy(target,handler)中的target)是一个带有全局对象Window所有属性及其值的对象,名为fakeWindow。当我们在子应用中通过window['a']=1新增或修改属性时,会触发Proxy实例的handler.set方法执行,此时他会做以下操作:

    我们再来一段代码来理解一下沙箱的效果:

    // 1. 主应用加载时定义window.app
    window.app = "masterapp";
    
    // 2. 子应用读取window.app的值
    console.log(window.app); // 显示'master-app'
    
    // 3. 子应用A更改window.app的值,然后在子应用A中读取是'micro-a',但如果在主应用中读取依旧是'master-app'
    window.app = "micro-a";
    console.log(window.app); // 'micro-a'
    
    // 3. 切换到子应用B且读取window.app的值时,此时子应用A已销毁,window.app会从'micro-a'撤回为'masterapp'
    console.log(window.user); // 'master-app'

    qiankun设置这种js沙箱是为了隔离子应用和主应用的window。但这种沙箱只能隔离Window的一级属性。因为Proxy只会捕获到一级属性的增删改,不能捕获到二级以上属性的变动,我们可以通过下图的控制台操作得出此结论:

    微前端接入Sentry的不完美但已尽力的实践总结_第6张图片 image.png

    因此,当在子应用中执行window[app_name]=release时,只会修改fakeWindow[app_name]=release,因此不会影响到全局对象Window。但当子应用中执行window["$micro_app_release"][app_name] = release时,因为window["$micro_app_release"],即fakeWindow["$micro_app_release"]是从Window["$micro_app_release"]复制过来的,两者都指向同一个引用对象,因此改动其属性会同步到全局对象Window上。

    关于沙箱的源码分析,可看最近写的文章不懂 qiankun 原理?这篇文章五张图片带你迅速通晓。

    关于qiankun是如何把将子应用的window指向从全局对象Window换到Proxy实例的解答可以看本文下面的篇章 ## 3. 在beforeSend中做错误定位偏移处理

    1. 修改fakeWindow"a"属性为 1

    2. 如果是修改System等一些在白名单属性里的值,则会先把Window中的['xx']的目前值备份一下,然后在把新的值覆盖到Window的属性中(当子应用销毁时,会把备份的值重置到原本属性中)。但大多数属性包括"a"都不在白名单属性中,因此是不会修改到Window的同名属性里的。

3. 逻辑判断event来自哪个应用,并修改其release属性为子应用的release

下面展示error eventtransaction event的判断逻辑:

Sentry.init({
  dsn: "xx",
  environment: process.env.NODE_ENV,
  release: process.env.VUE_APP_RELEASE,
  attachStacktrace: true,
  integrations: [
    new BrowserTracing({
      // Sentry.vueRouterInstrumentation作用在于优化transaction event的来源数据
      // transaction event有一个属性transaction记录事件源自哪个页面,其值通常是页面的url,如果用了Sentry.vueRouterInstrumentation,则会把url换成在vue-router中注册的对应路由的name
      // 此处使用Sentry.vueRouterInstrumentation会便于我们判断transaction event的源自哪个应用
      routingInstrumentation: Sentry.vueRouterInstrumentation(router),
      tracePropagationTargets: ["localhost", "my-site-url.com", /^\//],
    }),
  ],
  // 判断error event的逻辑
  beforeSend(event, hint) {
    // hint是一个对象,其中有三个属性:event_id,originalException,syntheticException
    // originalException指原始错误对象,syntheticException指sentry捕获原始错误对象后包装形成的错误对象
    const { originalException } = hint;
    // originalException带有平时我们从控制台看到的错误栈信息,当我们输出originalException.stacks.split('\n')后会有以下结果:
    //  0: "Error: 1"
    //  1: "    at onClick (http://localhost:3001/static/js/bundle.js:829:17)"
    //  2: "    at HTMLUnknownElement.callCallback (http://localhost:3001/static/js/bundle.js:16911:18)"
    //  3: "    at HTMLUnknownElement.sentryWrapped (http://localhost:3000/static/js/chunk-vendors.js:2664:17)"
    //  4: "    at Object.invokeGuardedCallbackDev (http://localhost:3001/static/js/bundle.js:16955:20)"
    //  5: "    at invokeGuardedCallback (http://localhost:3001/static/js/bundle.js:17012:35)"
    // 我们可以通过判断错误栈中头部的错误信息发生在哪个应用的文件里,从而知道错误出自哪个应用,如下代码所示:

    const stacks = originalException.stack?.split("\n");
    let app;
    // unhandledrejection event事件的错误(例如Promise.reject没被处理引起而的报错)是没有stacks的,因此需要做判断处理。也因为如此,方案一判断不了子应用里的unhandledrejection的错误出自哪个应用。只能都放在主应用的release里
    if (stacks?.[0]) {
      // 开发环境和生产环境的子应用资源路径不同,需要区分判断
      if (process.env.NODE_ENV === "production") {
        if (stacks[0].includes("react-app")) {
          app = "react-ts-app";
        } else if (stacks[0].includes("vue-app")) {
          app = "vue-app";
        } else if (stacks[0].includes("vue3-app")) {
          app = "vue3-ts-app";
        } else {
          app = "master-app";
        }
      } else {
        if (stacks[0].includes("localhost:3001")) {
          app = "react-ts-app";
        } else if (stacks[0].includes("localhost:3002")) {
          app = "vue-app";
        } else if (stacks[0].includes("localhost:3004")) {
          app = "vue3-ts-app";
        } else {
          app = "master-app";
        }
      }
    }
    if (window["$micro_app_release"][app]) {
      event.release = window["$micro_app_release"][app];
    }
    return event;
  },
  // 判断transaction event的逻辑
  beforeSendTransaction(event) {
    const releaseMap = {
      AppReact: "react-ts-app",
      AppVue: "vue-app",
      AppVue3: "vue3-ts-app",
    };
    // 根据transaction event的transaction属性判断其源自哪个应用
    const { transaction } = event;
    const app = releaseMap[transaction];
    if (window["$micro_app_release"][app]) {
      event.release = window["$micro_app_release"][app];
    }
    return event;
  },
  tracesSampleRate: 1.0,
});

至此,方案一代码实现完毕。

疑问解答:

  1. 为什么unhandledrejection event是没有错误栈的?

    js中,Error实例才带有stacks属性。通常Error可以通过window.onerror去捕获。

    但未被catch处理的Promise.reject并不会生成Error实例,它只会生成PromiseRejectionEvent事件且触发window.onunhandledrejection监听函数的执行。我们也可以看看window.onerrorwindow.onunhandledrejection的接口区别:

    window.onerror = (
      event, // 触发onerror的ErrorEvent实例
      source, // string,显示错误源自哪个文件
      lineno // number,显示错误源自上述文件的哪一行
      colno,// number,显示错误源自上述文件的哪一列
      error // Error实例,其中我们可以通过Error.prototype.stacks查看错误栈
      )=>{}
    
    window.onunhandledrejection = (
      event // 触发onunhandledrejection的PromiseRejectionEvent实例,继承于Event类,有promises和reason两个只读属性
    )=>{}

    总结来说,普通的错误在生成Error实例同时还会生成ErrorEvent实例,ErrorEvent实例会触发window.onerror的监听函数的执行。而未被catch处理的Promise.reject只会生成PromiseRejectionEvent实例,不会生成Error实例,因此是不存在错误栈信息的。

至此,整个方案一的接入过程已经讲述完。接下来我们分析一下方案一目前存在什么缺点。

方案一缺点分析

目前方案一存在的缺点有:

  1. 主应用和子应用只能通过release来区分,当子应用因迭代发布版本多起来的时候,会显得很混乱。

  2. 对于unhandledrejection event不能区分源自哪个应用。

方案二(首要推荐)

Sentry部分概念介绍

在介绍方案二前,我们要先了解Sentry中的三个概念,如下所示:

  • **Client(用户端)**:我们可以理解成放在应用里收集和上传event的实例。在项目中我们可以创建单独创建Client实例去进行信息收集和上报,如下代码所示:

    import {
      BrowserClient,
      defaultStackParser,
      defaultIntegrations,
      makeFetchTransport,
    } from "@sentry/browser";
    
    const client = new BrowserClient({
      dsn: "https://[email protected]/0",
      transport: makeFetchTransport,
      stackParser: defaultStackParser,
      integrations: defaultIntegrations,
    });
    
    client.captureException(new Error("example"));
  • **Scope(作用域)**:Scope是一个存储event信息的集合。例如event中的 contexts上下文(包含TagsUserLevel等用户浏览器信息和应用信息) 和 breadcrumbs面包屑信息都存放在Scope实例里。

    Sentry的默认插件就自动帮我们创建、发送和销毁Scope实例。除此之外,开发者也可以手动编辑其中的信息,如下所示:

    // 全局Scope修改,上报任何event之前都会执行以下逻辑
    Sentry.configureScope(function (scope) {
      scope.setTag("my-tag", "my value");
      scope.setUser({
        id: 42,
        email: "[email protected]",
      });
    });
    
    // 局部Scope修改
    Sentry.withScope(function (scope) {
      scope.setTag("my-tag", "my value");
      scope.setLevel("warning");
      // 上述信息仅在这次captureException中上报,其余event在上报时不会带上
      Sentry.captureException(new Error("my error"));
    });

    最终这些信息会发送给服务端,而我们可以在Sentry管理平台中看到,如下所示:

    微前端接入Sentry的不完美但已尽力的实践总结_第7张图片 image.png 微前端接入Sentry的不完美但已尽力的实践总结_第8张图片 image.png
  • **Hub(中心)**:Hub用于管理Scope实例栈。Client实例自身是缺乏上述configureScopewithScope方法的,在这种情况下,我们需要创建Hub实例然后绑定Client实例,才能够管理Scope信息,如下所示:

    import {
      BrowserClient,
      defaultStackParser,
      defaultIntegrations,
      makeFetchTransport,
    } from "@sentry/browser";
    
    const client = new BrowserClient({
      dsn: "https://[email protected]/0",
      transport: makeFetchTransport,
      stackParser: defaultStackParser,
      integrations: defaultIntegrations,
    });
    
    const hub = new Hub(client);
    
    hub.configureScope(function (scope) {
      scope.setTag("a", "b");
    });
    
    hub.addBreadcrumb({ message: "crumb 1" });
    hub.captureMessage("test");
    
    try {
      a = b;
    } catch (e) {
      hub.captureException(e);
    }
    
    hub.withScope(function (scope) {
      hub.addBreadcrumb({ message: "crumb 2" });
      hub.captureMessage("test2");
    });

当我们在主应用中通过Sentry.init去初始化Sentry实例时,其实其内部也做了 创建Client实例和Hub实例,把Hub实例关联到window上(存在多个Hub实例时,仅能有一个与window关联然后负责执行收集上报信息),最后把Client实例绑定到Hub实例上。 大家如果对此过程的源码有兴趣可以直接从这里看起,重点留意initAndBind方法。

思路分析

从上面的概念介绍中,我们知道可以独立创建Client实例,且从参数中可知,每个Client实例都可以带不同的dsn进行上报,那么,我们有以下设计思路:

  1. 对主应用和每个子应用都创建一个Client实例和Hub实例。

  2. 对路由进行监听,当路由变化时,根据页面URL判断是否加载了子应用,若是则切换到含子应用的页面,则更改当前关联windowHub实例。

因为同一时间只能有一个Hub实例执行上报工作,这种思路只适合单实例场景(同一时间只会渲染一个微应用),且比较适合那种除了菜单就只是子应用的页面,如下所示。因为大多数情况下,菜单这类页面基础组件都不会报错,所以我们可以放心把此路由下所有的报错都归类为对应的子应用。

微前端接入Sentry的不完美但已尽力的实践总结_第9张图片 image.png

代码实现

接下来依旧通过代码来展示如何实现方案而的思路,整个实现过程总结成以下三步:

1. 编写用于切换和创建Hub实例的函数

我们把这个函数命名为usingSentryHub,代码逻辑如下所示:

import { BrowserTracing } from "@sentry/tracing";
import {
  attachErrorHandler,
  createTracingMixins,
  vueRouterInstrumentation,
} from "@sentry/vue";
import {
  makeFetchTransport,
  makeMain,
  defaultStackParser,
  defaultIntegrations,
  Hub,
  BrowserClient,
} from "@sentry/browser";

// 当前关联window的Hub实例的名字
let currentHubName;

// 用于存放主应用和子应用的对象,key值是应用的名称
const hubMap = {};

/**
 * type: 应用所使用的框架,目前只支持'vue'和'react'
 * name: 应用的名称
 * settings: 初始化client实例时需要的配置
 */
export function usingSentryHub(type, name, settings) {
  if (name === currentHubName) return;
  if (hubMap[name]) {
    // makeMain用于切换绑定window的Hub实例
    makeMain(hubMap[name]);
    currentHubName = name;

    // 如果hubMap[name]不存在且settings不为空,则根据type调用不同的函数创建新的Hub实例
  } else if (settings) {
    switch (type) {
      case "vue":
        hubMap[name] = initVueSentryHub(settings);
        break;
      case "react":
        hubMap[name] = initReactSentryHub(settings);
        break;
      default:
        break;
    }
    makeMain(hubMap[name]);
    currentHubName = name;
  }
}

// 用于创建应用框架为vue的Hub实例
function initVueSentryHub({ Vue, router, options, VueOptions }) {
  const integrations = [...defaultIntegrations];
  if (router) {
    integrations.push(
      new BrowserTracing({
        routingInstrumentation: vueRouterInstrumentation(router),
        tracingOrigins: ["localhost", /^\//],
      })
    );
  }

  const ultimateOptions = {
    environment: process.env.NODE_ENV,
    transport: makeFetchTransport,
    stackParser: defaultStackParser,
    integrations,
    tracesSampleRate: 1.0,
    ...options,
  };
  const client = new BrowserClient(ultimateOptions);
  const ultimateVueOptions = {
    // 显示错误来源组件的props参数
    attachProps: true,
    // 控制台输出错误
    logErrors: true,
    tracesSampleRate: 1.0,
    ...VueOptions,
  };
  attachErrorHandler(Vue, ultimateVueOptions);
  if (
    "tracesSampleRate" in ultimateVueOptions ||
    "tracesSampler" in ultimateVueOptions
  ) {
    Vue.mixin(
      createTracingMixins({
        ...ultimateVueOptions,
        ...ultimateVueOptions.tracingOptions,
      })
    );
  }
  return new Hub(client);
}

// 用于创建应用框架为react的Hub实例
function initReactSentryHub({ options }) {
  const ultimateOptions = {
    environment: process.env.NODE_ENV,
    transport: makeFetchTransport,
    stackParser: defaultStackParser,
    integrations: [...defaultIntegrations],
    tracesSampleRate: 1.0,
    ...options,
  };
  const client = new BrowserClient(ultimateOptions);
  return new Hub(client);
}

2. 在路由变化的回调函数中调用usingSentryHub

// 在主应用的VueRouter.prototype.beforeEach里调用changeSentryHubWithRouter函数
router.beforeEach(async (to, from, next) => {
  changeSentryHubWithRouter(to);
  //...下面的代码省略
});

function changeSentryHubWithRouter(to) {
  // 通过meta知道当前路由是否含子应用,若有则直接切换到name对应的子应用Hub实例
  // 如果此时Hub实例还没创建,则不会切换
  if (to.meta?.microApp?.name) {
    usingSentryHub(undefined, to.meta.microApp.name);
  } else {
    // 如果路由不含子应用,则切换为主应用Hub实例,如果主应用的Hub实例还没创建,则根据第三形参进行创建
    usingSentryHub("vue", process.env.VUE_APP_NAME, {
      Vue,
      router,
      options: {
        dsn: "xxx", // 主应用dsn
        release: process.env.VUE_APP_RELEASE,
      },
    });
  }
}

3. 首次加载子应用时,创建子应用的Hub实例

子应用Client实例中的上报dsnrelease以及别的配置都是存放在子应用中的,而不是在存放在主应用代码里的,因此在首次加载子应用时,需要子应用通过通信把创建Client实例需要的数据上传给主应用。如下所示:

// vue2子应用逻辑
// 在mount钩子函数中创建。BrowserClient初始化需要在Vue实例mounted之前进行,因此要在mounted中实现
export async function mount(props) {
  const { container, basepath } = props;
  // 获取vue-router实例
  const router = getRouter(basepath);
  // 在本文的微前端项目中,子应用->主应用的通信是通过dispatchEvent+CustomEvent的方式实现的
  window.dispatchEvent(
    new CustomEvent("micro-app-dispatch", {
      detail: {
        type: "SET_MICRO_APP_HUB",
        payload: {
          type: "vue",
          name: process.env.VUE_APP_NAME,
          settings: {
            Vue, // 如果是Vue3应用,则先创建instance,然后把instance放在该形参上,然后最后才执行instance.$mount
            router,
            options: {
              dsn: "xxx", // 子应用的dsn
              release: process.env.VUE_APP_RELEASE,
            },
          },
        },
      },
    })
  );

  instance = new Vue({
    name: "VueApp",
    router: router,
    store,
    render: (h) => h(App),
    mixins: container ? [devtoolEnhanceMixin, uploadRoutesMixin] : undefined,
  }).$mount(container ? container.querySelector("#app") : "#app");
}

// react子应用
// 在bootstrap钩子函数中创建
export async function bootstrap() {
  window.dispatchEvent(
    new CustomEvent("micro-app-dispatch", {
      detail: {
        type: "SET_MICRO_APP_HUB",
        payload: {
          type: "react",
          name: process.env.REACT_APP_NAME,
          settings: {
            options: {
              dsn: "xxx",
              release: process.env.REACT_APP_RELEASE,
            },
          },
        },
      },
    })
  );
}

主应用在收到子应用通信发来的数据后,调用usingSentryHub函数去创建切换Hub实例,如下所示:

window.addEventListener("micro-app-dispatch", handleMicroAppDispatchEvent);

const handleMicroAppDispatchEvent = (e) => {
  const { detail: action } = e;
  switch (action.type) {
    case "SET_MICRO_APP_HUB":
      // eslint-disable-next-line no-case-declarations
      const { type, name, settings } = action.payload;
      usingSentryHub(type, name, settings);
      break;
    // ..其余通信省略
    default:
      break;
  }
};

至此,方案二代码实现完毕。

疑问解答:

  1. 为什么要使用dispatchEvent+CustomEvent作为通信方式?不可以直接用qiankun提供的action.onGlobalStateChange通信

    原因有两个:

    304d7cd6a6d31a5fdd3269bc381da17e.png image.png
    1. 本文微前端项目中的通信分主应用->子应用子应用->主应用两个方向,主应用->子应用方向才用qiankun提供的initGlobalStateAPI子应用->主应用方向才用原生js提供的dispatchEvent+CustomEvent。分开两个通信方向用不同的技术实现是为了防止数据流迷乱,在出 bug 后不利于排查。

    2. 目前qiankunglobalState标记为下一个版本弃用,在控制台中我们可以看到下图的警告信息。所以为了升级兼容,我们尽量避免过多依赖globalState

方案二缺点分析

方案二可以弥补方案一中的两个缺点,但自身存在以下缺点:

  1. 只适用于单实例场景。

  2. 如果在子应用页面中,主应用出现报错,则这个错误会归属到子应用中。

如何处理sourcemap

分以下三步来解决:

1. webpack配置中开启source-map

首先要在项目的脚手架配置文件中配置开启source-map,如下所示:

const isProd = process.env.NODE_ENV === "production";

// vue-cli中
module.exports = {
  // productionSourceMap属性默认为true,千万不要手动把这个属性设为false
  productionSourceMap: true,
  configureWebpack: {
    devtool: isProd ? "hidden-source-map" : "eval-cheap-source-map",
  },
};

// @rescript/cli中
module.exports = {
  webpack: (config) => {
    config.devtool = isProd ? "hidden-source-map" : "eval-cheap-source-map";
    return config;
  },
};

目前sentry只支持source-maphidden-source-map两种类型的 map 文件。在开发环境中,我们选择用eval-cheap-source-map,这样子便于我们准确定位错误,且初次生成和重构的速度快。在生产环境中,当错误出现在浏览器控制台时,我们不像让它能映射出错误所在的源码的位置,但又希望在Sentry平台中查看错误时能知道错误所在的源码的位置,此时要使用hidden-source-map

hidden-source-map模式下,打包时会生成source-map类型的 map 文件,但在打包的js文件尾部是不会加上//# sourceMappingURL=(map文件名称)的,由此导致加载页面的时候是不会请求 map 文件的,因此在报错时,错误只能定位在打包后执行的代码里,不能定位到源码上。而当错误上报到Sentry平台时,平台会内部调用source-map库的API去做映射,然后在错误栈中显示错误所出现的源码。

关于source-map类型的选择可查看webpack官网devtool说明或我以前写过的文章[webpack 学习]梳理一下 sourcemap 的知识点。

关于Sentry平台会内部如何调用source-map库的API去做映射的原理从这个库@sugarat/source-map-cli去学习

2. webpack中添加SentryWebpackPlugin插件

SentryWebpackPlugin插件用于上传打包后的代码,方便Sentry平台做source-map错误映射。其配置如下:

const isProd = process.env.NODE_ENV === "production";
const SentryWebpackPlugin = require("@sentry/webpack-plugin");
// 在vue-cli中
const { defineConfig } = require("@vue/cli-service");

module.exports = defineConfig({
  // 要把parallel手动置为false,原因可查看:https://github.com/getsentry/sentry-webpack-plugin/issues/272
  parallel: false,
  chainWebpack(config) {
    // 生产环境中才上传打包文件
    config.when(isProd, (config) => {
      config.plugin("sentry-webpack-plugin").use(
        new SentryWebpackPlugin({
          include: "./dist",
          ignore: ["node_modules", "nginx"],
          // 指定本次版本
          release: process.env.VUE_APP_RELEASE,
          // urlPrefix要与publicPath保持一致
          urlPrefix: "~/vue-app",
        })
      );
    });
  },
})

// 在@rescript/cli中
const { appendWebpackPlugin } = require('@rescripts/utilities');

module.exports = {
  webpack: (config) => {
    if (isProd) {
      config = appendWebpackPlugin(
        new SentryCliPlugin({
          include: './build',
          ignore: ['node_modules', 'nginx'],
          release: process.env.REACT_APP_RELEASE,
          urlPrefix: '~/react-app',
        }),
        config,
      );
    }
    return config;
  }
}

上传后的文件如下所示:

微前端接入Sentry的不完美但已尽力的实践总结_第10张图片 image.png

所有资源文件都会加上urlPrefix中指定的前缀。

3. 在beforeSend中做错误定位偏移处理

首先要说一下为什么要做这一步处理:

qiankun加载子应用页面资源时,会有以下处理:

  1. 首先根据entry请求获取html页面,然后解析html代码拿到其中所有的script元素和link元素

  2. 解析所有的link元素取出其中的外链href,然后逐个请求外链拿到对应的css文件,然后把文件中的css代码以内联样式()插入到html代码上。然后把html代码插入到container指定的容器基座里。

  3. 解析所有的script元素,如果是外部脚本,则取出外链src,然后逐个请求外链拿到对应的js脚本代码;如果是内联脚本则先不处理。然后用getExecutableScript函数对这些脚本内容进行包裹处理,如下所示:

    /**
     * scriptSrc: 如果script是内联脚本,则直接是script标签字符串;如果是外链脚本,则是script.src,即脚本外链地址
     * scriptText: 脚本代码内容
     * proxy: js沙箱,就是我们在前面说到的用于隔离window的沙箱
     * strictGlobal: 布尔量,如果开启基于Proxy实现的沙箱则为true,否则为false。如果所处浏览器不支持Proxy,则会用不基于Proxy实现的沙箱,此时strictGlobal为false
     */
    function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
      const sourceUrl = isInlineCode(scriptSrc)
        ? ""
        : `//# sourceURL=${scriptSrc}\n`;
    
      // 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
      // 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
      const globalWindow = (0, eval)("window");
      globalWindow.proxy = proxy;
      // TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
      return strictGlobal
        ? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
        : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
    }

    经过用with可以把指定参数放到脚本的作用域链的顶部。如果脚本代码中调用与指定参数同名的变量,即使原本作用域链中已存在同名的属性,则该变量依旧会指向指定参数。因此,通过上面的操作会把window的指向从全局对象Window换成js沙箱。

    然后最后会通过evalCode函数来执行上述with处理后的代码,如下所示:

    export function evalCode(scriptSrc, code) {
      const key = scriptSrc;
      if (!evalCache[key]) {
        const functionWrappedCode = `window.__TEMP_EVAL_FUNC__ = function(){${code}}`;
        // 通过eval执行`with`处理后的代码
        (0, eval)(functionWrappedCode);
        evalCache[key] = window.__TEMP_EVAL_FUNC__;
        delete window.__TEMP_EVAL_FUNC__;
      }
      const evalFunc = evalCache[key];
      evalFunc.call(window);
    }

    因此,实际执行的代码,会比我们在打包后生成的代码中多处一层with包裹。我们也可以看下图,当点击查看子应用中抛出错误的运行代码时,可以看到打包代码被包裹着的样子:

    微前端接入Sentry的不完美但已尽力的实践总结_第11张图片 image.png 4d7a1555c78610119c1734a998755828.png image.png

正因为有这层包裹,当子应用有错误抛出时,指定错误出现位置的colno(列位置)就对应不到打包代码中的代码位置。因此我们要手动对错误的定位进行矫正,这里在Sentry提供的beforeSend钩子函数中做处理,代码如下所示:

beforeSend(event) {
  event.exception.values = event.exception.values.map((item) => {
    // unhandledrejection event不存在stacktrace,因此要做判断处理
    if (item.stacktrace) {
      const {
        stacktrace: { frames },
        ...rest
      } = item;
      // FIXME: 主应用加载时,qiankun 加载当前js资源会在首行添加 window.__TEMP_EVAL_FUNC__ = function(){;(function(window, self, globalThis){with(window){;
      // https://github.com/kuitos/import-html-entry/blob/master/src/index.js#L62
      frames[frames.length - 1].colno -=
        "window.__TEMP_EVAL_FUNC__ = function(){;(function(window, self, globalThis){with(window){;".length;
      return {
        ...rest,
        stacktrace: {
          frames,
        },
      };
    }
    return item;
  });
  return event;
},

注意:getExecutableScriptevalCode两个函数都是在import-html-entry这个库中实现的,qiankun引用了import-html-entry这个库对加载资源做处理。而不同版本的import-html-entrygetExecutableScriptevalCode的代码不同,因此偏移量也不同。因此当我们做这层处理时,可以打开浏览器的调试工具,如前面的图中去查看Source来看看运行代码中多了多少偏移量。

最终可以在Sentry后台中看到子应用的错误所出自的源码,如下所示:

微前端接入Sentry的不完美但已尽力的实践总结_第12张图片 image.png

目前存在的疑惑:目前在vue子应用中需要做偏移处理才能在Sentry后台中看到错误的精准定位,但react子应用却不需要做这层处理也可以看到错误的精准定位,自己也很好奇为什么react子应用不需要处理?有空研究一下create-react-app脚手架的源码后再继续更新这部分。

后记

这篇文章写到这里就结束了,如果觉得有用可以点赞收藏,如果有疑问可以直接在评论留言,欢迎交流 。

你可能感兴趣的:(前端,sentry,vue.js,javascript,ecmascript)