diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts index f4d3b2480d50..856fdcf56d9b 100644 --- a/packages/vue/src/integration.ts +++ b/packages/vue/src/integration.ts @@ -74,6 +74,36 @@ const vueInit = (app: Vue, options: Options): void => { } if (hasSpansEnabled(options)) { - app.mixin(createTracingMixins(options.tracingOptions)); + const mixins = createTracingMixins(options.tracingOptions); + app.mixin(mixins); + warnIfMixinWasDropped(app, mixins); } }; + +/** + * `app.mixin()` is a no-op when Options API is disabled (default in Nuxt 5). + * Without mixins (Options API) users lose every UI span (render, mount, etc.) + + * See: https://github.com/vuejs/core/blob/v3.5.41/packages/runtime-core/src/apiCreateApp.ts + */ +function warnIfMixinWasDropped(app: Vue, mixin: unknown): void { + // Vue 2 has no `_context` and no Options API flag, so there is nothing to check. + const mixins = (app as Vue & { _context?: { mixins?: unknown[] } })._context?.mixins; + + if (!mixins || mixins.includes(mixin)) { + return; + } + + // `createNuxtApp()` sets `$nuxt` on the Vue app before it calls the `app:created` hook (where Nuxt SDK adds this integration). + const fix = + '$nuxt' in app + ? 'Set `vue: { optionsApi: true }` in your `nuxt.config.ts`.' + : 'Set `__VUE_OPTIONS_API__` to `true` in the `define` config of your bundler.'; + + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + `[@sentry/vue]: The Vue Options API is disabled (\`__VUE_OPTIONS_API__: false\`), so Sentry cannot record UI spans. You lose \`Application Render\` and the component mount, update and unmount spans. Errors, pageload spans and navigation spans still work. ${fix}`, + ); + }); +} diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index 38fc3383783b..3a859415178e 100644 --- a/packages/vue/test/integration/mixinRegistration.test.ts +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -3,7 +3,8 @@ */ import { spanToJSON } from '@sentry/core'; -import { describe, expect, it as baseIt, vi } from 'vitest'; +import type { MockInstance } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it as baseIt, vi } from 'vitest'; import type { App } from 'vue'; import { createApp, h } from 'vue'; import * as Sentry from '../../src'; @@ -212,3 +213,67 @@ describe('tracing mixin span creation', () => { }); }); }); + +describe('Options API detection guard', () => { + const OPTIONS_API_WARNING = expect.stringContaining('The Vue Options API is disabled'); + + let consoleWarn: MockInstance; + + beforeEach(() => { + consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarn.mockRestore(); + }); + + it('warns when the app dropped the tracing mixin', ({ app, initSentry }) => { + app.mixin = () => app; + + initSentry(); + + expect(consoleWarn).toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); + + it('points a plain Vue app at its bundler config', ({ app, initSentry }) => { + app.mixin = () => app; + + initSentry(); + + expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining('`define` config of your bundler')); + expect(consoleWarn).not.toHaveBeenCalledWith(expect.stringContaining('nuxt.config.ts')); + }); + + // `createNuxtApp()` sets `$nuxt` on the Vue app before the `app:created` hook the Nuxt SDK uses. + it('points a Nuxt app at `nuxt.config.ts`', ({ app, initSentry }) => { + app.mixin = () => app; + Object.defineProperty(app, '$nuxt', { get: () => ({}) }); + + initSentry(); + + expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining('`vue: { optionsApi: true }`')); + }); + + it('does not warn when the app accepted the tracing mixin', ({ initSentry }) => { + initSentry(); + + expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); + + it('does not warn when tracing is disabled, because no mixin is registered', ({ app, initSentry }) => { + app.mixin = () => app; + + initSentry({ sdk: { tracesSampleRate: undefined } }); + + expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); + + // A Vue 2 constructor has no `_context`, so the guard has nothing to read back and must stay quiet. + it('does not warn for a Vue 2 constructor', ({ initSentry }) => { + const vue2Constructor = { config: {}, mixin: () => {} }; + + initSentry({ sdk: { app: undefined, Vue: vue2Constructor } }); + + expect(consoleWarn).not.toHaveBeenCalledWith(OPTIONS_API_WARNING); + }); +});