diff --git a/packages/vue/src/errorhandler.ts b/packages/vue/src/errorhandler.ts index 06caca796e1f..3cfc07bea2d7 100644 --- a/packages/vue/src/errorhandler.ts +++ b/packages/vue/src/errorhandler.ts @@ -4,34 +4,31 @@ import { formatComponentName, generateComponentTrace } from './vendor/components type UnknownFunc = (...args: unknown[]) => void; +/** + * Captures an exception with Vue component metadata. + * + * This can be used from a Vue `onErrorCaptured` hook when the automatic error handler is disabled or when an error + * boundary stops the error from propagating to the application-level handler. + * + * @param handled - Whether the boundary stops the error from propagating. + */ +export const captureVueException = ( + error: unknown, + vm: ViewModel | null, + lifecycleHook: string, + handled: boolean, + options?: Partial, +): void => { + captureVueExceptionWithMechanism(error, vm, lifecycleHook, handled, options); +}; + export const attachErrorHandler = (app: Vue, options?: Partial): void => { const { errorHandler: originalErrorHandler } = app.config; - app.config.errorHandler = (error: Error, vm: ViewModel, lifecycleHook: string): void => { - const componentName = formatComponentName(vm, false); - const trace = vm ? generateComponentTrace(vm) : ''; - const metadata: Record = { - componentName, - lifecycleHook, - trace, - }; - - if (options?.attachProps !== false && vm) { - // Vue2 - $options.propsData - // Vue3 - $props - if (vm.$options?.propsData) { - metadata.propsData = vm.$options.propsData; - } else if (vm.$props) { - metadata.propsData = vm.$props; - } - } - + app.config.errorHandler = (error: unknown, vm: ViewModel | null, lifecycleHook: string): void => { // Capture exception in the next event loop, to make sure that all breadcrumbs are recorded in time. setTimeout(() => { - captureException(error, { - captureContext: { contexts: { vue: metadata } }, - mechanism: { handled: !!originalErrorHandler, type: 'auto.function.vue.error_handler' }, - }); + captureVueExceptionWithMechanism(error, vm, lifecycleHook, !!originalErrorHandler, options); }); // Check if the current `app.config.errorHandler` is explicitly set by the user before calling it. @@ -42,3 +39,34 @@ export const attachErrorHandler = (app: Vue, options?: Partial): voi } }; }; + +function captureVueExceptionWithMechanism( + error: unknown, + vm: ViewModel | null, + lifecycleHook: string, + handled: boolean, + options?: Partial, +): void { + const componentName = formatComponentName(vm || undefined, false); + const trace = vm ? generateComponentTrace(vm) : ''; + const metadata: Record = { + componentName, + lifecycleHook, + trace, + }; + + if (options?.attachProps !== false && vm) { + // Vue2 - $options.propsData + // Vue3 - $props + if (vm.$options?.propsData) { + metadata.propsData = vm.$options.propsData; + } else if (vm.$props) { + metadata.propsData = vm.$props; + } + } + + captureException(error, { + captureContext: { contexts: { vue: metadata } }, + mechanism: { handled, type: 'auto.function.vue.error_handler' }, + }); +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 3e870ff1062b..79691183e556 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -5,7 +5,8 @@ export * from '@sentry/browser'; export { init } from './sdk'; export { browserTracingIntegration } from './browserTracingIntegration'; -export { attachErrorHandler } from './errorhandler'; +export { attachErrorHandler, captureVueException } from './errorhandler'; +export type { ViewModel } from './types'; export { createTracingMixins } from './tracing'; export { vueIntegration } from './integration'; export type { VueIntegrationOptions } from './integration'; diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 6f61fc4e6104..04caf96ce96a 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -14,9 +14,9 @@ export interface Vue { export type ViewModel = { _isVue?: boolean; __isVue?: boolean; - $root: ViewModel; - $parent?: ViewModel; - $props: { [key: string]: any }; + $root?: ViewModel | null; + $parent?: ViewModel | null; + $props?: { [key: string]: any }; $options?: { name?: string; propsData?: { [key: string]: any }; diff --git a/packages/vue/src/vendor/components.ts b/packages/vue/src/vendor/components.ts index 0972f81cdb94..3d8836faab46 100644 --- a/packages/vue/src/vendor/components.ts +++ b/packages/vue/src/vendor/components.ts @@ -16,7 +16,7 @@ const repeat = (str: string, n: number): string => { return str.repeat(n); }; -export const formatComponentName = (vm?: ViewModel, includeFile?: boolean): string => { +export const formatComponentName = (vm?: ViewModel | null, includeFile?: boolean): string => { if (!vm) { return ANONYMOUS_COMPONENT_NAME; } @@ -46,7 +46,7 @@ export const formatComponentName = (vm?: ViewModel, includeFile?: boolean): stri ); }; -export const generateComponentTrace = (vm?: ViewModel): string => { +export const generateComponentTrace = (vm?: ViewModel | null): string => { if (vm && (vm._isVue || vm.__isVue) && vm.$parent) { const tree = []; let currentRecursiveSequence = 0; diff --git a/packages/vue/test/errorHandler.test.ts b/packages/vue/test/errorHandler.test.ts index f70c561fcc66..75fbf48eb148 100644 --- a/packages/vue/test/errorHandler.test.ts +++ b/packages/vue/test/errorHandler.test.ts @@ -1,6 +1,6 @@ import { setCurrentClient } from '@sentry/browser'; import { afterEach, describe, expect, it, test, vi } from 'vitest'; -import { attachErrorHandler } from '../src/errorhandler'; +import { attachErrorHandler, captureVueException } from '../src/errorhandler'; import type { Operation, Options, ViewModel, Vue } from '../src/types'; describe('attachErrorHandler', () => { @@ -231,6 +231,38 @@ describe('attachErrorHandler', () => { }); }); +describe('captureVueException', () => { + it.each([true, false])('captures an exception synchronously with handled=%s', handled => { + const captureException = vi.fn(); + setCurrentClient({ captureException } as any); + const error = new DummyError(); + const vm = { + $options: { name: 'error-boundary' }, + $props: { source: 'checkout' }, + } as ViewModel; + + captureVueException(error, vm, 'render', handled); + + expect(captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + captureContext: { + contexts: { + vue: { + componentName: '', + lifecycleHook: 'render', + propsData: { source: 'checkout' }, + trace: '\n\n(found in )', + }, + }, + }, + mechanism: { handled, type: 'auto.function.vue.error_handler' }, + }), + expect.anything(), + ); + }); +}); + type TestHarnessOpts = { // I don't need everything in the tests vm: Partial | null; diff --git a/packages/vue/test/integration/errorHandler.test.ts b/packages/vue/test/integration/errorHandler.test.ts new file mode 100644 index 000000000000..a5edfd082dcf --- /dev/null +++ b/packages/vue/test/integration/errorHandler.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment jsdom + */ + +import type { Scope } from '@sentry/core'; +import { setCurrentClient } from '@sentry/browser'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createApp, defineComponent, h, onErrorCaptured } from 'vue'; +import { captureVueException, withScope } from '../../src'; + +describe('captureVueException', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it('captures a Vue error boundary exception with its local scope', () => { + const error = 'render failed'; + const captureException = vi.fn((_error: unknown, _hint: unknown, scope?: Scope) => { + expect(scope?.getScopeData().tags).toEqual({ boundary: 'checkout' }); + }); + setCurrentClient({ captureException } as any); + + const child = defineComponent({ + name: 'Checkout', + setup() { + throw error; + }, + render: () => h('div'), + }); + const boundary = defineComponent({ + name: 'ErrorBoundary', + setup() { + onErrorCaptured((caughtError, instance, info) => { + withScope(scope => { + scope.setTag('boundary', 'checkout'); + captureVueException(caughtError, instance, info, true); + }); + + expect(captureException).toHaveBeenCalledTimes(1); + return false; + }); + + return () => h(child); + }, + }); + const app = createApp(boundary); + + app.mount(document.createElement('div')); + + expect(captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + captureContext: { + contexts: { + vue: expect.objectContaining({ + componentName: '', + lifecycleHook: 'setup function', + }), + }, + }, + mechanism: { handled: true, type: 'auto.function.vue.error_handler' }, + }), + expect.anything(), + ); + app.unmount(); + }); +});