From f52f52bfaf5d58b9a9a1d2ce18657abe41d4d94f Mon Sep 17 00:00:00 2001 From: vansh-nagar Date: Tue, 22 Sep 2026 16:09:23 +0530 Subject: [PATCH 1/3] feat(vue): Export Vue exception capture helper Co-Authored-By: OpenAI/ChatGPT --- packages/vue/src/errorhandler.ts | 69 ++++++++++++++++++-------- packages/vue/src/index.ts | 3 +- packages/vue/test/errorHandler.test.ts | 32 +++++++++++- 3 files changed, 80 insertions(+), 24 deletions(-) diff --git a/packages/vue/src/errorhandler.ts b/packages/vue/src/errorhandler.ts index 06caca796e1f..d0532f4b13c6 100644 --- a/packages/vue/src/errorhandler.ts +++ b/packages/vue/src/errorhandler.ts @@ -4,34 +4,28 @@ 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. + */ +export const captureVueException = ( + error: Error, + vm: ViewModel, + lifecycleHook: string, + options?: Partial, +): void => { + captureVueExceptionWithMechanism(error, vm, lifecycleHook, false, 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; - } - } - // 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 +36,34 @@ export const attachErrorHandler = (app: Vue, options?: Partial): voi } }; }; + +function captureVueExceptionWithMechanism( + error: Error, + vm: ViewModel, + lifecycleHook: string, + handled: boolean, + options?: Partial, +): 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; + } + } + + 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/test/errorHandler.test.ts b/packages/vue/test/errorHandler.test.ts index f70c561fcc66..618b9d5e2dd6 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,36 @@ describe('attachErrorHandler', () => { }); }); +describe('captureVueException', () => { + it('captures an exception synchronously with Vue metadata', () => { + 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'); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException.mock.calls[0][0]).toBe(error); + expect(captureException.mock.calls[0][1]).toMatchObject({ + captureContext: { + contexts: { + vue: { + componentName: '', + lifecycleHook: 'render', + propsData: { source: 'checkout' }, + trace: '\n\n(found in )', + }, + }, + }, + mechanism: { handled: false, type: 'auto.function.vue.error_handler' }, + }); + }); +}); + type TestHarnessOpts = { // I don't need everything in the tests vm: Partial | null; From 9d9ee854fe45517c87300cdf3e3127625ba07f47 Mon Sep 17 00:00:00 2001 From: vansh-nagar Date: Tue, 22 Sep 2026 22:21:34 +0530 Subject: [PATCH 2/3] fix(vue): support error boundary hook values Co-Authored-By: OpenAI Codex --- packages/vue/src/errorhandler.ts | 12 ++-- packages/vue/src/types.ts | 6 +- packages/vue/src/vendor/components.ts | 4 +- packages/vue/test/errorHandler.test.ts | 28 ++++---- .../vue/test/integration/errorHandler.test.ts | 66 +++++++++++++++++++ 5 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 packages/vue/test/integration/errorHandler.test.ts diff --git a/packages/vue/src/errorhandler.ts b/packages/vue/src/errorhandler.ts index d0532f4b13c6..709d5237d5bd 100644 --- a/packages/vue/src/errorhandler.ts +++ b/packages/vue/src/errorhandler.ts @@ -11,8 +11,8 @@ type UnknownFunc = (...args: unknown[]) => void; * boundary stops the error from propagating to the application-level handler. */ export const captureVueException = ( - error: Error, - vm: ViewModel, + error: unknown, + vm: ViewModel | null, lifecycleHook: string, options?: Partial, ): void => { @@ -22,7 +22,7 @@ export const captureVueException = ( export const attachErrorHandler = (app: Vue, options?: Partial): void => { const { errorHandler: originalErrorHandler } = app.config; - app.config.errorHandler = (error: Error, vm: ViewModel, lifecycleHook: string): void => { + 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(() => { captureVueExceptionWithMechanism(error, vm, lifecycleHook, !!originalErrorHandler, options); @@ -38,13 +38,13 @@ export const attachErrorHandler = (app: Vue, options?: Partial): voi }; function captureVueExceptionWithMechanism( - error: Error, - vm: ViewModel, + error: unknown, + vm: ViewModel | null, lifecycleHook: string, handled: boolean, options?: Partial, ): void { - const componentName = formatComponentName(vm, false); + const componentName = formatComponentName(vm || undefined, false); const trace = vm ? generateComponentTrace(vm) : ''; const metadata: Record = { componentName, 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 618b9d5e2dd6..4d921ad0efe6 100644 --- a/packages/vue/test/errorHandler.test.ts +++ b/packages/vue/test/errorHandler.test.ts @@ -243,21 +243,23 @@ describe('captureVueException', () => { captureVueException(error, vm, 'render'); - expect(captureException).toHaveBeenCalledTimes(1); - expect(captureException.mock.calls[0][0]).toBe(error); - expect(captureException.mock.calls[0][1]).toMatchObject({ - captureContext: { - contexts: { - vue: { - componentName: '', - lifecycleHook: 'render', - propsData: { source: 'checkout' }, - trace: '\n\n(found in )', + expect(captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + captureContext: { + contexts: { + vue: { + componentName: '', + lifecycleHook: 'render', + propsData: { source: 'checkout' }, + trace: '\n\n(found in )', + }, }, }, - }, - mechanism: { handled: false, type: 'auto.function.vue.error_handler' }, - }); + mechanism: { handled: false, type: 'auto.function.vue.error_handler' }, + }), + expect.anything(), + ); }); }); diff --git a/packages/vue/test/integration/errorHandler.test.ts b/packages/vue/test/integration/errorHandler.test.ts new file mode 100644 index 000000000000..aa780fc6b8e6 --- /dev/null +++ b/packages/vue/test/integration/errorHandler.test.ts @@ -0,0 +1,66 @@ +/** + * @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); + }); + + 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', + }), + }, + }, + }), + expect.anything(), + ); + app.unmount(); + }); +}); From 6a0cc0018e0b328a1c1cd5ad64e79c34aefb6972 Mon Sep 17 00:00:00 2001 From: vansh-nagar Date: Tue, 22 Sep 2026 22:30:39 +0530 Subject: [PATCH 3/3] fix(vue): preserve boundary handled state Co-Authored-By: OpenAI Codex --- packages/vue/src/errorhandler.ts | 5 ++++- packages/vue/test/errorHandler.test.ts | 6 +++--- packages/vue/test/integration/errorHandler.test.ts | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/vue/src/errorhandler.ts b/packages/vue/src/errorhandler.ts index 709d5237d5bd..3cfc07bea2d7 100644 --- a/packages/vue/src/errorhandler.ts +++ b/packages/vue/src/errorhandler.ts @@ -9,14 +9,17 @@ type UnknownFunc = (...args: unknown[]) => void; * * 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, false, options); + captureVueExceptionWithMechanism(error, vm, lifecycleHook, handled, options); }; export const attachErrorHandler = (app: Vue, options?: Partial): void => { diff --git a/packages/vue/test/errorHandler.test.ts b/packages/vue/test/errorHandler.test.ts index 4d921ad0efe6..75fbf48eb148 100644 --- a/packages/vue/test/errorHandler.test.ts +++ b/packages/vue/test/errorHandler.test.ts @@ -232,7 +232,7 @@ describe('attachErrorHandler', () => { }); describe('captureVueException', () => { - it('captures an exception synchronously with Vue metadata', () => { + it.each([true, false])('captures an exception synchronously with handled=%s', handled => { const captureException = vi.fn(); setCurrentClient({ captureException } as any); const error = new DummyError(); @@ -241,7 +241,7 @@ describe('captureVueException', () => { $props: { source: 'checkout' }, } as ViewModel; - captureVueException(error, vm, 'render'); + captureVueException(error, vm, 'render', handled); expect(captureException).toHaveBeenCalledWith( error, @@ -256,7 +256,7 @@ describe('captureVueException', () => { }, }, }, - mechanism: { handled: false, type: 'auto.function.vue.error_handler' }, + mechanism: { handled, type: 'auto.function.vue.error_handler' }, }), expect.anything(), ); diff --git a/packages/vue/test/integration/errorHandler.test.ts b/packages/vue/test/integration/errorHandler.test.ts index aa780fc6b8e6..a5edfd082dcf 100644 --- a/packages/vue/test/integration/errorHandler.test.ts +++ b/packages/vue/test/integration/errorHandler.test.ts @@ -33,7 +33,7 @@ describe('captureVueException', () => { onErrorCaptured((caughtError, instance, info) => { withScope(scope => { scope.setTag('boundary', 'checkout'); - captureVueException(caughtError, instance, info); + captureVueException(caughtError, instance, info, true); }); expect(captureException).toHaveBeenCalledTimes(1); @@ -58,6 +58,7 @@ describe('captureVueException', () => { }), }, }, + mechanism: { handled: true, type: 'auto.function.vue.error_handler' }, }), expect.anything(), );