Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Export @sentry/vue errorHandler #11471

Open
swantzter opened this issue Apr 8, 2024 · 4 comments
Open

Export @sentry/vue errorHandler #11471

swantzter opened this issue Apr 8, 2024 · 4 comments
Labels
Feature: Errors Package: vue Issues related to the Sentry Vue SDK Type: Improvement

Comments

@swantzter
Copy link

swantzter commented Apr 8, 2024

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
})
@swantzter
Copy link
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

@swantzter
Copy link
Author

swantzter commented Apr 8, 2024

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

@Lms24
Copy link
Member

Lms24 commented Apr 10, 2024

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.

@swantzter
Copy link
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature: Errors Package: vue Issues related to the Sentry Vue SDK Type: Improvement
Projects
Status: No status
Development

No branches or pull requests

3 participants