diff --git a/packages/vue/src/errorhandler.ts b/packages/vue/src/errorhandler.ts index 542d341c322f..900bed5a5074 100644 --- a/packages/vue/src/errorhandler.ts +++ b/packages/vue/src/errorhandler.ts @@ -1,12 +1,12 @@ import { getCurrentHub } from '@sentry/browser'; import { addExceptionMechanism } from '@sentry/utils'; -import type { Options, ViewModel, Vue } from './types'; +import type { ViewModel, Vue, VueOptions } from './types'; import { formatComponentName, generateComponentTrace } from './vendor/components'; type UnknownFunc = (...args: unknown[]) => void; -export const attachErrorHandler = (app: Vue, options: Options): void => { +export const attachErrorHandler = (app: Vue, options: VueOptions): void => { const { errorHandler, warnHandler, silent } = app.config; app.config.errorHandler = (error: Error, vm: ViewModel, lifecycleHook: string): void => { diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 24a352aba99a..6afcc0f60ae8 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -4,3 +4,4 @@ export { init } from './sdk'; export { vueRouterInstrumentation } from './router'; export { attachErrorHandler } from './errorhandler'; export { createTracingMixins } from './tracing'; +export { VueIntegration } from './integration'; diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts new file mode 100644 index 000000000000..9a3969f5c415 --- /dev/null +++ b/packages/vue/src/integration.ts @@ -0,0 +1,99 @@ +import { hasTracingEnabled } from '@sentry/core'; +import type { Hub, Integration } from '@sentry/types'; +import { arrayify, GLOBAL_OBJ } from '@sentry/utils'; + +import { DEFAULT_HOOKS } from './constants'; +import { attachErrorHandler } from './errorhandler'; +import { createTracingMixins } from './tracing'; +import type { Options, Vue, VueOptions } from './types'; + +const globalWithVue = GLOBAL_OBJ as typeof GLOBAL_OBJ & { Vue: Vue }; + +const DEFAULT_CONFIG: VueOptions = { + Vue: globalWithVue.Vue, + attachProps: true, + logErrors: true, + hooks: DEFAULT_HOOKS, + timeout: 2000, + trackComponents: false, +}; + +/** + * Initialize Vue error & performance tracking. + */ +export class VueIntegration implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'Vue'; + + /** + * @inheritDoc + */ + public name: string; + + private readonly _options: Partial; + + public constructor(options: Partial = {}) { + this.name = VueIntegration.id; + this._options = options; + } + + /** @inheritDoc */ + public setupOnce(_addGlobaleventProcessor: unknown, getCurrentHub: () => Hub): void { + this._setupIntegration(getCurrentHub()); + } + + /** Just here for easier testing */ + protected _setupIntegration(hub: Hub): void { + const client = hub.getClient(); + const options: Options = { ...DEFAULT_CONFIG, ...(client && client.getOptions()), ...this._options }; + + if (!options.Vue && !options.app) { + // eslint-disable-next-line no-console + console.warn( + `[@sentry/vue]: Misconfigured SDK. Vue specific errors will not be captured. +Update your \`Sentry.init\` call with an appropriate config option: +\`app\` (Application Instance - Vue 3) or \`Vue\` (Vue Constructor - Vue 2).`, + ); + return; + } + + if (options.app) { + const apps = arrayify(options.app); + apps.forEach(app => vueInit(app, options)); + } else if (options.Vue) { + vueInit(options.Vue, options); + } + } +} + +const vueInit = (app: Vue, options: Options): void => { + // Check app is not mounted yet - should be mounted _after_ init()! + // This is _somewhat_ private, but in the case that this doesn't exist we simply ignore it + // See: https://github.com/vuejs/core/blob/eb2a83283caa9de0a45881d860a3cbd9d0bdd279/packages/runtime-core/src/component.ts#L394 + const appWithInstance = app as Vue & { + _instance?: { + isMounted?: boolean; + }; + }; + + const isMounted = appWithInstance._instance && appWithInstance._instance.isMounted; + if (isMounted === true) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/vue]: Misconfigured SDK. Vue app is already mounted. Make sure to call `app.mount()` after `Sentry.init()`.', + ); + } + + attachErrorHandler(app, options); + + if (hasTracingEnabled(options)) { + app.mixin( + createTracingMixins({ + ...options, + ...options.tracingOptions, + }), + ); + } +}; diff --git a/packages/vue/src/sdk.ts b/packages/vue/src/sdk.ts index ecc879bccbd7..21d7246f503c 100644 --- a/packages/vue/src/sdk.ts +++ b/packages/vue/src/sdk.ts @@ -1,34 +1,7 @@ -import { init as browserInit, SDK_VERSION } from '@sentry/browser'; -import { hasTracingEnabled } from '@sentry/core'; -import { arrayify, GLOBAL_OBJ } from '@sentry/utils'; +import { defaultIntegrations, init as browserInit, SDK_VERSION } from '@sentry/browser'; -import { DEFAULT_HOOKS } from './constants'; -import { attachErrorHandler } from './errorhandler'; -import { createTracingMixins } from './tracing'; -import type { Options, TracingOptions, Vue } from './types'; - -const globalWithVue = GLOBAL_OBJ as typeof GLOBAL_OBJ & { Vue: Vue }; - -const DEFAULT_CONFIG: Options = { - Vue: globalWithVue.Vue, - attachProps: true, - logErrors: true, - hooks: DEFAULT_HOOKS, - timeout: 2000, - trackComponents: false, - _metadata: { - sdk: { - name: 'sentry.javascript.vue', - packages: [ - { - name: 'npm:@sentry/vue', - version: SDK_VERSION, - }, - ], - version: SDK_VERSION, - }, - }, -}; +import { VueIntegration } from './integration'; +import type { Options, TracingOptions } from './types'; /** * Inits the Vue SDK @@ -37,56 +10,21 @@ export function init( config: Partial & { tracingOptions: Partial }> = {}, ): void { const options = { - ...DEFAULT_CONFIG, + _metadata: { + sdk: { + name: 'sentry.javascript.vue', + packages: [ + { + name: 'npm:@sentry/vue', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }, + }, + defaultIntegrations: [...defaultIntegrations, new VueIntegration()], ...config, }; browserInit(options); - - if (!options.Vue && !options.app) { - // eslint-disable-next-line no-console - console.warn( - `[@sentry/vue]: Misconfigured SDK. Vue specific errors will not be captured. -Update your \`Sentry.init\` call with an appropriate config option: -\`app\` (Application Instance - Vue 3) or \`Vue\` (Vue Constructor - Vue 2).`, - ); - return; - } - - if (options.app) { - const apps = arrayify(options.app); - apps.forEach(app => vueInit(app, options)); - } else if (options.Vue) { - vueInit(options.Vue, options); - } } - -const vueInit = (app: Vue, options: Options): void => { - // Check app is not mounted yet - should be mounted _after_ init()! - // This is _somewhat_ private, but in the case that this doesn't exist we simply ignore it - // See: https://github.com/vuejs/core/blob/eb2a83283caa9de0a45881d860a3cbd9d0bdd279/packages/runtime-core/src/component.ts#L394 - const appWithInstance = app as Vue & { - _instance?: { - isMounted?: boolean; - }; - }; - - const isMounted = appWithInstance._instance && appWithInstance._instance.isMounted; - if (isMounted === true) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/vue]: Misconfigured SDK. Vue app is already mounted. Make sure to call `app.mount()` after `Sentry.init()`.', - ); - } - - attachErrorHandler(app, options); - - if (hasTracingEnabled(options)) { - app.mixin( - createTracingMixins({ - ...options, - ...options.tracingOptions, - }), - ); - } -}; diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 1cc39b97b887..2a1ee6d89046 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -25,11 +25,13 @@ export type ViewModel = { }; }; -export interface Options extends TracingOptions, BrowserOptions { +export interface VueOptions extends TracingOptions { /** Vue constructor to be used inside the integration (as imported by `import Vue from 'vue'` in Vue2) */ Vue?: Vue; - /** Vue app instance(s) to be used inside the integration (as generated by `createApp` in Vue3 ) */ + /** + * Vue app instance(s) to be used inside the integration (as generated by `createApp` in Vue3). + */ app?: Vue | Vue[]; /** @@ -48,6 +50,8 @@ export interface Options extends TracingOptions, BrowserOptions { tracingOptions?: Partial; } +export interface Options extends BrowserOptions, VueOptions {} + /** Vue specific configuration for Tracing Integration */ export interface TracingOptions { /** diff --git a/packages/vue/test/integration/VueIntegration.test.ts b/packages/vue/test/integration/VueIntegration.test.ts new file mode 100644 index 000000000000..22f53df4c498 --- /dev/null +++ b/packages/vue/test/integration/VueIntegration.test.ts @@ -0,0 +1,68 @@ +import { logger } from '@sentry/utils'; +import { createApp } from 'vue'; + +import * as Sentry from '../../src'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +describe('Sentry.VueIntegration', () => { + let loggerWarnings: unknown[] = []; + let warnings: unknown[] = []; + + beforeEach(() => { + warnings = []; + loggerWarnings = []; + + jest.spyOn(logger, 'warn').mockImplementation((message: unknown) => { + loggerWarnings.push(message); + }); + + jest.spyOn(console, 'warn').mockImplementation((message: unknown) => { + warnings.push(message); + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('allows to initialize integration later', () => { + Sentry.init({ dsn: PUBLIC_DSN, defaultIntegrations: false, autoSessionTracking: false }); + + const el = document.createElement('div'); + const app = createApp({ + template: '
hello
', + }); + + // This would normally happen through client.addIntegration() + const integration = new Sentry.VueIntegration({ app }); + integration['_setupIntegration'](Sentry.getCurrentHub()); + + app.mount(el); + + expect(warnings).toEqual([]); + expect(loggerWarnings).toEqual([]); + + expect(app.config.errorHandler).toBeDefined(); + }); + + it('warns when mounting before SDK.VueIntegration', () => { + Sentry.init({ dsn: PUBLIC_DSN, defaultIntegrations: false, autoSessionTracking: false }); + + const el = document.createElement('div'); + const app = createApp({ + template: '
hello
', + }); + + app.mount(el); + + // This would normally happen through client.addIntegration() + const integration = new Sentry.VueIntegration({ app }); + integration['_setupIntegration'](Sentry.getCurrentHub()); + + expect(warnings).toEqual([ + '[@sentry/vue]: Misconfigured SDK. Vue app is already mounted. Make sure to call `app.mount()` after `Sentry.init()`.', + ]); + expect(loggerWarnings).toEqual([]); + }); +}); diff --git a/packages/vue/test/integration/init.test.ts b/packages/vue/test/integration/init.test.ts index a9936c97bc89..e176e5b1691c 100644 --- a/packages/vue/test/integration/init.test.ts +++ b/packages/vue/test/integration/init.test.ts @@ -1,24 +1,23 @@ import { createApp } from 'vue'; +import { VueIntegration } from '../../src/integration'; +import type { Options } from '../../src/types'; import * as Sentry from './../../src'; +const PUBLIC_DSN = 'https://username@domain/123'; + describe('Sentry.init', () => { - let _consoleWarn: any; - let warnings: string[] = []; + let warnings: unknown[] = []; beforeEach(() => { warnings = []; - // eslint-disable-next-line no-console - _consoleWarn = console.warn; - // eslint-disable-next-line no-console - console.warn = jest.fn((message: string) => { + jest.spyOn(console, 'warn').mockImplementation((message: unknown) => { warnings.push(message); }); }); afterEach(() => { - // eslint-disable-next-line no-console - console.warn = _consoleWarn; + jest.clearAllMocks(); }); it('does not warn when correctly setup (Vue 3)', () => { @@ -27,9 +26,8 @@ describe('Sentry.init', () => { template: '
hello
', }); - Sentry.init({ + runInit({ app, - defaultIntegrations: false, }); app.mount(el); @@ -43,10 +41,9 @@ describe('Sentry.init', () => { template: '
hello
', }); - Sentry.init({ + runInit({ // this is a bit "hacky", but good enough to test what we want Vue: app, - defaultIntegrations: false, }); app.mount(el); @@ -62,9 +59,8 @@ describe('Sentry.init', () => { app.mount(el); - Sentry.init({ + runInit({ app, - defaultIntegrations: false, }); expect(warnings).toEqual([ @@ -78,9 +74,7 @@ describe('Sentry.init', () => { template: '
hello
', }); - Sentry.init({ - defaultIntegrations: false, - }); + runInit({}); app.mount(el); @@ -90,4 +84,41 @@ Update your \`Sentry.init\` call with an appropriate config option: \`app\` (Application Instance - Vue 3) or \`Vue\` (Vue Constructor - Vue 2).`, ]); }); + + it('does not warn when skipping Vue integration', () => { + const el = document.createElement('div'); + const app = createApp({ + template: '
hello
', + }); + + Sentry.init({ + dsn: PUBLIC_DSN, + defaultIntegrations: false, + integrations: [], + }); + + app.mount(el); + + expect(warnings).toEqual([]); + }); }); + +function runInit(options: Partial): void { + const hasRunBefore = Sentry.getCurrentHub().getIntegration(VueIntegration); + + const integration = new VueIntegration(); + + Sentry.init({ + dsn: PUBLIC_DSN, + defaultIntegrations: false, + integrations: [integration], + ...options, + }); + + // Because our integrations API is terrible to test, we need to make sure to check + // If we've already had this integration registered before + // if that's the case, `setup()` will not be run, so we need to manually run it :( + if (hasRunBefore) { + integration['_setupIntegration'](Sentry.getCurrentHub()); + } +}