Skip to content

Export @sentry/vue errorHandler #11471

Closed as not planned
Closed as not planned

Description

@swantzter

Problem Statement

I'm trying to write an ErrorBoundary component to scope components to the right vue app in our nested-vue-app microfrontends setup in order to capture them with specific tags to make our multiplexed transport route them correctly (as described on https://docs.sentry.io/platforms/javascript/guides/vue/best-practices/micro-frontends/#manually-route-errors-to-different-projects)

I have a component looking like this where I'd like to add some tags to the scope, then have it processed with Sentry's error handler, without having it propagate higher up to other ErrorBoundaries

import { type SlotsType, defineComponent, onErrorCaptured } from 'vue'
import { withScope, captureException } from '@sentry/vue'

/**
 * This is a transparent component that captures errors propagating from
 * descendant components and captures it with sentry with extra information
 * about the microfrontend to ensure it's routed to the correct sentry project.
 *
 * It renders the default slot with props passed through transparently.
 */
export default defineComponent({
  name: 'ErrorBoundary',
  slots: Object as SlotsType<{ default: Record<string, unknown> }>,
  setup (props, { slots, attrs }) {
    onErrorCaptured((err, vm, info) => {
      withScope(scope => {
        scope.setTag('appName', `${__APP_NAME__}`)
        scope.setExtra('vueInfo', info)
        // TODO: Here I'd like to "forward" the error to sentry's own error handler to get it further formatted right and captured with the right hints etc. and logged with sentry's logErrors logger
        captureException(err)
      })
      return false
    })

    return () => slots.default(props)
  }
})

Solution Brainstorm

Either, it'd be nice to export https://github.com/getsentry/sentry-javascript/blob/develop/packages/vue/src/errorhandler.ts#L12-L56 so you can create a "custom" instance of that error handler

Alternatively, is there maybe a way to set these tags at the current scope when it gets to the error handler, keep propagating it, and skip setting those tags if they're already set at higher error boundary functions?

The documentation recommends against it, but could something like this work perhaps?

onErrorCaptured((_err, vm, info) => {
  const pCtx = getCurrentScope().getPropagationContext() as PropagationContext & { mfTagged?: boolean }
  if (pCtx.mfTagged) return true
  setTag('appName', `${__APP_NAME__}`)
  getCurrentScope().setPropagationContext({
    ...pCtx,
    mfTagged: true
  } as unknown as PropagationContext)
  return true
})

Activity

  1. moved this to Waiting for: Product Owner in GitHub Issues with 👀 2on Apr 8, 2024
  2. swantzter commented on Apr 8, 2024

    @swantzter
    Author

    Well upon further testing, the second solution that lets the event propogate doesn't work - it seemingly attaches to the top level scope (which, I guess I expected) and thus locks all future errors to that appName

  3. swantzter commented on Apr 8, 2024

    @swantzter
    Author

    To be able to ship as we need error reporting yesterday I've ended up vendoring a modified version of https://github.com/getsentry/sentry-javascript/blob/develop/packages/vue/src/errorhandler.ts#L12-L56

    Using an ErrorBoundary.vue component looking like this
    import { type SlotsType, defineComponent, onErrorCaptured } from 'vue'
    import { sentryErrorHandler } from '../sentry'
    import { type ViewModel } from '@sentry/vue/types/types'
    
    /**
     * This is a transparent component that captures errors propagating from
     * descendant components and captures it with sentry with extra information
     * about the microfrontend to ensure it's routed to the correct sentry project.
     *
     * It renders the default slot with props passed through transparently.
     */
    export default defineComponent({
      name: 'ErrorBoundary',
      slots: Object as SlotsType<{ default: Record<string, unknown> }>,
      setup (props, { slots, attrs }) {
        onErrorCaptured((err, vm, info) => {
          sentryErrorHandler(err, vm as ViewModel, info, __APP_NAME__)
          return false
        })
    
        return () => slots.default(props)
      }
    })
    And sentry.ts looking like this
    /**
     * Source: https://github.com/getsentry/sentry-javascript/blob/83f7ccec7298d77c862b83fce4ef6c1439c31a7e/packages/vue/src/errorhandler.ts
     * @license MIT - Copyright (c) 2019 Sentry (https://sentry.io) and individual contributors. All rights reserved.
     */
    export function sentryErrorHandler (error: Error, vm: ViewModel, lifecycleHook: string, appName: string): void {
      const componentName = formatComponentName(vm, false)
      const trace = vm ? generateComponentTrace(vm) : ''
      const metadata: Record<string, unknown> = {
        componentName,
        lifecycleHook,
        trace
      }
    
      if (vm) {
        // Vue2 - $options.propsData
        // Vue3 - $props
        if (vm.$options?.propsData) {
          metadata.propsData = vm.$options.propsData
        } else if (vm.$props) {
          metadata.propsData = vm.$props
        }
      }
    
      // Capture exception in the next event loop, to make sure that all breadcrumbs are recorded in time.
      setTimeout(() => {
        withScope(s => {
          s.setTag('appName', appName)
          s.captureException(error, {
            captureContext: { contexts: { vue: metadata } },
            mechanism: { handled: false }
          })
        })
      })
    
      const hasConsole = typeof console !== 'undefined'
      // eslint-disable-next-line @typescript-eslint/prefer-optional-chain
      const message = `Error in ${lifecycleHook}: "${error && error.toString()}"`
    
      if (hasConsole) {
        consoleSandbox(() => {
          // eslint-disable-next-line no-console
          console.error(`[Vue warn]: ${message}${trace}`)
        })
      }
    }
    
    /**
     * Vendored from https://github.com/vuejs/vue/blob/612fb89547711cacb030a3893a0065b785802860/src/core/util/debug.js
     * with types only changes.
     *
     * @license MIT - Copyright (c) 2013-present, Yuxi (Evan) You
     * @license MIT - Copyright (c) 2019 Sentry (https://sentry.io) and individual contributors. All rights reserved.
    */
    
    const classifyRE = /(?:^|[-_])(\w)/g
    const classify = (str: string): string => str.replace(classifyRE, c => c.toUpperCase()).replace(/[-_]/g, '')
    
    const ROOT_COMPONENT_NAME = '<Root>'
    const ANONYMOUS_COMPONENT_NAME = '<Anonymous>'
    
    const repeat = (str: string, n: number): string => {
      return str.repeat(n)
    }
    
    export const formatComponentName = (vm?: ViewModel, includeFile?: boolean): string => {
      if (!vm) {
        return ANONYMOUS_COMPONENT_NAME
      }
    
      if (vm.$root === vm) {
        return ROOT_COMPONENT_NAME
      }
    
      // https://github.com/getsentry/sentry-javascript/issues/5204 $options can be undefined
      if (!vm.$options) {
        return ANONYMOUS_COMPONENT_NAME
      }
    
      const options = vm.$options
    
      // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
      let name = options.name || options._componentTag
      const file = options.__file
      if (!name && file) {
        const match = file.match(/([^/\\]+)\.vue$/)
        if (match) {
          name = match[1]
        }
      }
    
      return (
        (name ? `<${classify(name)}>` : ANONYMOUS_COMPONENT_NAME) + (file && includeFile !== false ? ` at ${file}` : '')
      )
    }
    
    export const generateComponentTrace = (vm?: ViewModel): string => {
      // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
      if (vm && (vm._isVue || vm.__isVue) && vm.$parent) {
        const tree = []
        let currentRecursiveSequence = 0
        while (vm) {
          if (tree.length > 0) {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            const last = tree[tree.length - 1] as any
            // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
            if (last.constructor === vm.constructor) {
              currentRecursiveSequence++
              vm = vm.$parent // eslint-disable-line no-param-reassign
              continue
            } else if (currentRecursiveSequence > 0) {
              tree[tree.length - 1] = [last, currentRecursiveSequence]
              currentRecursiveSequence = 0
            }
          }
          tree.push(vm)
          vm = vm.$parent // eslint-disable-line no-param-reassign
        }
    
        const formattedTree = tree
          .map(
            (vm, i) =>
              `${
                (i === 0 ? '---> ' : repeat(' ', 5 + i * 2)) +
                (Array.isArray(vm)
                  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
                  ? `${formatComponentName(vm[0])}... (${vm[1]} recursive calls)`
                  : formatComponentName(vm))
              }`
          )
          .join('\n')
    
        return `\n\nfound in\n\n${formattedTree}`
      }
    
      return `\n\n(found in ${formatComponentName(vm)})`
    }

    the main change to the latter is that I hardcoded our settings, and added a withScope() around the captureException() call in the setTimeout() to be able to tag the scope with the appName, which I'm also passing in as an extra param to the error handler

  4. Lms24 commented on Apr 10, 2024

    @Lms24
    Member

    Hey @swantzter apologies for the late reply!

    Are you fine with the custom code you wrote for this or do you still need a specific export from the SDK? We could generally think about exporting the errorHandler but we're a bit swamped with tasks at the moment, so I'm just trying to prioritize.

    As for the ask around the scopes and propagation context: I believe I answered this (at least partially) here. Short version: There's very little we can do to retain scope integrity for MFE setups globally as long as there is no async context handling strategy for browsers.

  5. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 2on Apr 10, 2024
  6. swantzter commented on Apr 11, 2024

    @swantzter
    Author

    @Lms24 no worries. The custom code route I went with works well for now, but it feels a little risky to keep in sync in case you change the error handler upstream.
    So I wouldn't say this should be top priority, but I do think somewhere down the line this would still be very useful, and ideally a way to hook into it to add to the scope before the captureException

  7. moved this to Waiting for: Product Owner in GitHub Issues with 👀 2on Apr 11, 2024
  8. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 2on Apr 11, 2024
  9. Lms24 commented on Sep 28, 2026

    @Lms24
    Member

    I am going to close this because the issue has been stale for >2 years. If anyone still would like to get an export of our errorHandler, or an API as proposed in #24586, please feel free to tag me so that we can reopen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions